diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml new file mode 100644 index 0000000000..4dcdd20f72 --- /dev/null +++ b/.github/workflows/check-pr-template.yml @@ -0,0 +1,80 @@ +name: Check PR Template + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, edited] + +permissions: {} + +jobs: + parse: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.head.repo.fork == true }} + permissions: + contents: read + outputs: + uses_template: ${{ steps.check.outputs.uses_template }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .github/pull_request_template.md + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Check required sections + id: check + env: + BODY: ${{ github.event.pull_request.body }} + run: | + OK=true + while IFS= read -r header; do + printf '%s\n' "$BODY" | grep -qF "$header" || OK=false + done < <(sed '//d' .github/pull_request_template.md | grep "^## ") + echo "uses_template=$OK" >> "$GITHUB_OUTPUT" + + act: + runs-on: ubuntu-latest + needs: parse + permissions: + pull-requests: write + steps: + - name: Close PR + if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \ + -f query=' + mutation CommentAndClosePR($prId: ID!, $body: String!) { + addComment(input: { + subjectId: $prId, + body: $body + }) { + __typename + } + closePullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' + + - name: Reopen PR (sections now present, PR closed) + if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f query=' + mutation ReopenPR($prId: ID!) { + reopenPullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1636076491..2573ba8123 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -131,7 +131,7 @@ jobs: - device: rocm suffixes: '-rocm' platforms: linux/amd64 - runner-mapping: '{"linux/amd64": "pokedex-giant"}' + runner-mapping: '{"linux/amd64": "pokedex-large"}' uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 permissions: contents: read diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml deleted file mode 100644 index 93e18a4fcc..0000000000 --- a/.github/workflows/release-pr.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: Manage release PR -on: - workflow_dispatch: - push: - branches: - - main - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -permissions: {} - -jobs: - bump: - runs-on: ubuntu-latest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} - private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - persist-credentials: true - ref: main - - - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - - - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - - - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version-file: './server/.nvmrc' - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' - - - name: Determine release type - id: bump-type - uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0 - with: - token: ${{ steps.generate-token.outputs.token }} - - - name: Bump versions - env: - TYPE: ${{ steps.bump-type.outputs.bump }} - run: | - if [ "$TYPE" == "none" ]; then - exit 1 # TODO: Is there a cleaner way to abort the workflow? - fi - misc/release/pump-version.sh -s $TYPE -m true - - - name: Manage Outline release document - id: outline - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - NEXT_VERSION: ${{ steps.bump-type.outputs.next }} - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const fs = require('fs'); - - const outlineKey = process.env.OUTLINE_API_KEY; - const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9' - const collectionId = 'e2910656-714c-4871-8721-447d9353bd73'; - const baseUrl = 'https://outline.immich.cloud'; - - const listResponse = await fetch(`${baseUrl}/api/documents.list`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ parentDocumentId }) - }); - - if (!listResponse.ok) { - throw new Error(`Outline list failed: ${listResponse.statusText}`); - } - - const listData = await listResponse.json(); - const allDocuments = listData.data || []; - - const document = allDocuments.find(doc => doc.title === 'next'); - - let documentId; - let documentUrl; - let documentText; - - if (!document) { - // Create new document - console.log('No existing document found. Creating new one...'); - const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8'); - const createResponse = await fetch(`${baseUrl}/api/documents.create`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - title: 'next', - text: notesTmpl, - collectionId: collectionId, - parentDocumentId: parentDocumentId, - publish: true - }) - }); - - if (!createResponse.ok) { - throw new Error(`Failed to create document: ${createResponse.statusText}`); - } - - const createData = await createResponse.json(); - documentId = createData.data.id; - const urlId = createData.data.urlId; - documentUrl = `${baseUrl}/doc/next-${urlId}`; - documentText = createData.data.text || ''; - console.log(`Created new document: ${documentUrl}`); - } else { - documentId = document.id; - const docPath = document.url; - documentUrl = `${baseUrl}${docPath}`; - documentText = document.text || ''; - console.log(`Found existing document: ${documentUrl}`); - } - - // Generate GitHub release notes - console.log('Generating GitHub release notes...'); - const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: `${process.env.NEXT_VERSION}`, - }); - - // Combine the content - const changelog = ` - # ${process.env.NEXT_VERSION} - - ${documentText} - - ${releaseNotesResponse.data.body} - - --- - - ` - - const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : ''; - fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8'); - - core.setOutput('document_url', documentUrl); - - - name: Create PR - id: create-pr - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 - with: - token: ${{ steps.generate-token.outputs.token }} - commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' - title: 'chore: release ${{ steps.bump-type.outputs.next }}' - body: 'Release notes: ${{ steps.outline.outputs.document_url }}' - labels: 'changelog:skip' - branch: 'release/next' - draft: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 30e9c1c7ca..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: release.yml -on: - pull_request: - types: [closed] - paths: - - CHANGELOG.md - -jobs: - # Maybe double check PR source branch? - - merge_translations: - uses: ./.github/workflows/merge-translations.yml - permissions: - pull-requests: write - secrets: - PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }} - PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} - - build_mobile: - uses: ./.github/workflows/build-mobile.yml - needs: merge_translations - permissions: - contents: read - secrets: - KEY_JKS: ${{ secrets.KEY_JKS }} - ALIAS: ${{ secrets.ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} - # iOS secrets - APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} - IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} - with: - ref: main - environment: production - - prepare_release: - runs-on: ubuntu-latest - needs: build_mobile - permissions: - actions: read # To download the app artifact - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} - private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - persist-credentials: false - ref: main - - - name: Extract changelog - id: changelog - run: | - CHANGELOG_PATH=$RUNNER_TEMP/changelog.md - sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH - echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT - VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download APK - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: release-apk-signed - github-token: ${{ steps.generate-token.outputs.token }} - - - name: Create draft release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 - with: - tag_name: ${{ steps.version.outputs.result }} - token: ${{ steps.generate-token.outputs.token }} - body_path: ${{ steps.changelog.outputs.path }} - draft: true - files: | - docker/docker-compose.yml - docker/docker-compose.rootless.yml - docker/example.env - docker/hwaccel.ml.yml - docker/hwaccel.transcoding.yml - docker/prometheus.yml - *.apk - - - name: Rename Outline document - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - continue-on-error: true - env: - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - VERSION: ${{ steps.changelog.outputs.version }} - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const outlineKey = process.env.OUTLINE_API_KEY; - const version = process.env.VERSION; - const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'; - const baseUrl = 'https://outline.immich.cloud'; - - const listResponse = await fetch(`${baseUrl}/api/documents.list`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ parentDocumentId }) - }); - - if (!listResponse.ok) { - throw new Error(`Outline list failed: ${listResponse.statusText}`); - } - - const listData = await listResponse.json(); - const allDocuments = listData.data || []; - const document = allDocuments.find(doc => doc.title === 'next'); - - if (document) { - console.log(`Found document 'next', renaming to '${version}'...`); - - const updateResponse = await fetch(`${baseUrl}/api/documents.update`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: document.id, - title: version - }) - }); - - if (!updateResponse.ok) { - throw new Error(`Failed to rename document: ${updateResponse.statusText}`); - } - } else { - console.log('No document titled "next" found to rename'); - } diff --git a/cli/package.json b/cli/package.json index aed8be5bba..d6202e6a1a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@vitest/coverage-v8": "^4.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c132c224aa..6e435b3c6b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -155,7 +155,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 3a5f781d5e..4d07794fea 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 7cbec36eb6..eb41bf9bca 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f016955b32..4437087d24 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e-auth-server/auth-server.ts b/e2e-auth-server/auth-server.ts index a190ecd023..9aef56510d 100644 --- a/e2e-auth-server/auth-server.ts +++ b/e2e-auth-server/auth-server.ts @@ -10,6 +10,7 @@ export enum OAuthClient { export enum OAuthUser { NO_EMAIL = 'no-email', NO_NAME = 'no-name', + ID_TOKEN_CLAIMS = 'id-token-claims', WITH_QUOTA = 'with-quota', WITH_USERNAME = 'with-username', WITH_ROLE = 'with-role', @@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({ email_verified: true, }); -const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +const getClaims = (sub: string, use?: string) => { + if (sub === OAuthUser.ID_TOKEN_CLAIMS) { + return { + sub, + email: `oauth-${sub}@immich.app`, + email_verified: true, + name: use === 'id_token' ? 'ID Token User' : 'Userinfo User', + }; + } + return claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +}; const setup = async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); - const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect']; + const redirectUris = [ + 'http://127.0.0.1:2285/auth/login', + 'https://photos.immich.app/oauth/mobile-redirect', + ]; const port = 2286; const host = '0.0.0.0'; const oidc = new Provider(`http://${host}:${port}`, { @@ -66,7 +80,10 @@ const setup = async () => { console.error(error); ctx.body = 'Internal Server Error'; }, - findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }), + findAccount: (ctx, sub) => ({ + accountId: sub, + claims: (use) => getClaims(sub, use), + }), scopes: ['openid', 'email', 'profile'], claims: { openid: ['sub'], @@ -94,6 +111,7 @@ const setup = async () => { state: 'oidc.state', }, }, + conformIdTokenClaims: false, pkce: { required: () => false, }, @@ -125,7 +143,10 @@ const setup = async () => { ], }); - const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`); + const onStart = () => + console.log( + `[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`, + ); const app = oidc.listen(port, host, onStart); return () => app.close(); }; diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 7f117ee37c..957de4698e 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 diff --git a/e2e/package.json b/e2e/package.json index 962cf86ea3..34aedf3c46 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -32,7 +32,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index cbd68c003a..ae9064375f 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -380,4 +380,23 @@ describe(`/oauth`, () => { }); }); }); + + describe('idTokenClaims', () => { + it('should use claims from the ID token if IDP includes them', async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + }); + const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS); + const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); + expect(status).toBe(201); + expect(body).toMatchObject({ + accessToken: expect.any(String), + name: 'ID Token User', + userEmail: 'oauth-id-token-claims@immich.app', + userId: expect.any(String), + }); + }); + }); }); 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 80232beb75..00c455d6cb 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -438,6 +438,16 @@ 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/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts index 3f9bb4237a..88b61278bc 100644 --- a/e2e/src/specs/web/photo-viewer.e2e-spec.ts +++ b/e2e/src/specs/web/photo-viewer.e2e-spec.ts @@ -1,14 +1,13 @@ import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; -import { Page, expect, test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import type { Socket } from 'socket.io-client'; import { utils } from 'src/utils'; -function imageLocator(page: Page) { - return page.getByAltText('Image taken').locator('visible=true'); -} test.describe('Photo Viewer', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; let rawAsset: AssetMediaResponseDto; + let websocket: Socket; test.beforeAll(async () => { utils.initSdk(); @@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => { admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + test.afterAll(() => { + utils.disconnectWebsocket(websocket); }); test.beforeEach(async ({ context, page }) => { @@ -26,31 +30,51 @@ test.describe('Photo Viewer', () => { test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const originalResponse = page.waitForResponse((response) => response.url().includes('/original')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + + await originalResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /original/); }); test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { await page.goto(`/photos/${rawAsset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); + + await fullsizeResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /fullsize/); }); test('reloads photo when checksum changes', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const initialSrc = await imageLocator(page).getAttribute('src'); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + const initialSrc = await preview.getAttribute('src'); + + const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); await utils.replaceAsset(admin.accessToken, asset.id); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + await websocketEvent; + + await expect(preview).not.toHaveAttribute('src', initialSrc!); }); }); diff --git a/e2e/src/specs/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts index f6d1ec98d4..8380840935 100644 --- a/e2e/src/specs/web/shared-link.e2e-spec.ts +++ b/e2e/src/specs/web/shared-link.e2e-spec.ts @@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils'; test.describe('Shared Links', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let asset2: AssetMediaResponseDto; let album: AlbumResponseDto; let sharedLink: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto; + let individualSharedLink: SharedLinkResponseDto; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + asset2 = await utils.createAsset(admin.accessToken); album = await createAlbum( { createAlbumDto: { @@ -39,6 +42,10 @@ test.describe('Shared Links', () => { albumId: album.id, password: 'test-password', }); + individualSharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id, asset2.id], + }); }); test('download from a shared link', async ({ page }) => { @@ -109,4 +116,21 @@ test.describe('Shared Links', () => { await page.waitForURL('/photos'); await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); }); + + test('owner can remove assets from an individual shared link', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto(`/share/${individualSharedLink.key}`); + await page.locator(`[data-asset="${asset.id}"]`).waitFor(); + await expect(page.locator(`[data-asset]`)).toHaveCount(2); + + await page.locator(`[data-asset="${asset.id}"]`).hover(); + await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click(); + + await page.getByRole('button', { name: 'Remove from shared link' }).click(); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + + await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0); + await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1); + }); }); diff --git a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts index fa010f0c1b..2b036d3f52 100644 --- a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts @@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => { test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => { await context.route( - (url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`), + (url) => + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) || + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`), async (route) => { return route.fulfill({ status: 404 }); }, @@ -73,7 +75,7 @@ test.describe('broken-asset responsiveness', () => { await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.waitForSelector('#immich-asset-viewer'); - const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]'); + const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first(); await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); diff --git a/i18n/en.json b/i18n/en.json index 802c60399f..e926434f6d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1009,6 +1009,8 @@ "editor_edits_applied_success": "Edits applied successfully", "editor_flip_horizontal": "Flip horizontal", "editor_flip_vertical": "Flip vertical", + "editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle", + "editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle", "editor_orientation": "Orientation", "editor_reset_all_changes": "Reset changes", "editor_rotate_left": "Rotate 90° counterclockwise", @@ -1074,7 +1076,7 @@ "failed_to_update_notification_status": "Failed to update notification status", "incorrect_email_or_password": "Incorrect email or password", "library_folder_already_exists": "This import path already exists.", - "page_not_found": "Page not found :/", + "page_not_found": "Page not found", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "quota_higher_than_disk_size": "You set a quota higher than the disk size", diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 0839000dd0..103cf79e4e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -3,6 +3,7 @@ plugins { id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" id 'com.google.devtools.ksp' + id 'org.jetbrains.kotlin.plugin.serialization' id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version } @@ -112,6 +113,8 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation 'org.chromium.net:cronet-embedded:143.7445.0' + implementation("androidx.media3:media3-datasource-okhttp:1.9.2") + implementation("androidx.media3:media3-datasource-cronet:1.9.2") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index a85929a0e9..06649de8f0 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.NetworkApiPlugin +import me.albemala.native_video_player.NativeVideoPlayerPlugin import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi @@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() { companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { HttpClientManager.initialize(ctx) + NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory flutterEngine.plugins.add(NetworkApiPlugin()) val messenger = flutterEngine.dartExecutor.binaryMessenger diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 37435a9f02..e7268396e8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -3,23 +3,41 @@ package app.alextran.immich.core import android.content.Context import android.content.SharedPreferences import android.security.KeyChain +import androidx.annotation.OptIn import androidx.core.content.edit +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cronet.CronetDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import app.alextran.immich.BuildConfig import app.alextran.immich.NativeBuffer import okhttp3.Cache import okhttp3.ConnectionPool +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.Credentials import okhttp3.Dispatcher import okhttp3.Headers -import okhttp3.Credentials +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient -import org.json.JSONObject +import org.chromium.net.CronetEngine +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.File +import java.net.Authenticator +import java.net.CookieHandler +import java.net.PasswordAuthentication import java.net.Socket +import java.net.URI import java.security.KeyStore import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext @@ -32,13 +50,26 @@ private const val CERT_ALIAS = "client_cert" private const val PREFS_NAME = "immich.ssl" private const val PREFS_CERT_ALIAS = "immich.client_cert" private const val PREFS_HEADERS = "immich.request_headers" -private const val PREFS_SERVER_URL = "immich.server_url" +private const val PREFS_SERVER_URLS = "immich.server_urls" +private const val PREFS_COOKIES = "immich.cookies" +private const val COOKIE_EXPIRY_DAYS = 400L + +private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) { + ACCESS_TOKEN("immich_access_token", httpOnly = true), + IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false), + AUTH_TYPE("immich_auth_type", httpOnly = true); + + companion object { + val names = entries.map { it.cookieName }.toSet() + } +} /** * Manages a shared OkHttpClient with SSL configuration support. */ object HttpClientManager { private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB + const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB private const val KEEP_ALIVE_CONNECTIONS = 10 private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val MAX_REQUESTS_PER_HOST = 64 @@ -50,6 +81,11 @@ object HttpClientManager { private lateinit var appContext: Context private lateinit var prefs: SharedPreferences + var cronetEngine: CronetEngine? = null + private set + private lateinit var cronetStorageDir: File + val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4) + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } var keyChainAlias: String? = null @@ -58,6 +94,8 @@ object HttpClientManager { var headers: Headers = Headers.headersOf() private set + private val cookieJar = PersistentCookieJar() + val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) fun initialize(context: Context) { @@ -69,18 +107,48 @@ object HttpClientManager { prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) + cookieJar.init(prefs) + System.setProperty("http.agent", USER_AGENT) + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + val url = requestingURL ?: return null + if (url.userInfo.isNullOrEmpty()) return null + val parts = url.userInfo.split(":", limit = 2) + return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray()) + } + }) + CookieHandler.setDefault(object : CookieHandler() { + override fun get(uri: URI, requestHeaders: Map>): Map> { + val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap() + val cookies = cookieJar.loadForRequest(httpUrl) + if (cookies.isEmpty()) return emptyMap() + return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" })) + } + + override fun put(uri: URI, responseHeaders: Map>) {} + }) + val savedHeaders = prefs.getString(PREFS_HEADERS, null) if (savedHeaders != null) { - val json = JSONObject(savedHeaders) + val map = Json.decodeFromString>(savedHeaders) val builder = Headers.Builder() - for (key in json.keys()) { - builder.add(key, json.getString(key)) + for ((key, value) in map) { + builder.add(key, value) } headers = builder.build() } + val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null) + if (serverUrlsJson != null) { + cookieJar.setServerUrls(Json.decodeFromString>(serverUrlsJson)) + } + val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) + + cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() } + cronetEngine = buildCronetEngine() + initialized = true } } @@ -153,25 +221,97 @@ object HttpClientManager { synchronized(this) { clientChangedListeners.add(listener) } } - fun setRequestHeaders(headerMap: Map, serverUrls: List) { + fun setRequestHeaders(headerMap: Map, serverUrls: List, token: String?) { synchronized(this) { val builder = Headers.Builder() headerMap.forEach { (key, value) -> builder[key] = value } val newHeaders = builder.build() + val headersChanged = headers != newHeaders - val newUrl = serverUrls.firstOrNull() - val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null) - if (!headersChanged && !urlChanged) return + val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null) + headers = newHeaders - prefs.edit { - if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString()) - if (urlChanged) { - if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL) + cookieJar.setServerUrls(serverUrls) + + if (headersChanged || urlsChanged) { + prefs.edit { + putString(PREFS_HEADERS, Json.encodeToString(headerMap)) + putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls)) } } + + if (token != null) { + val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return + val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000 + val values = mapOf( + AuthCookie.ACCESS_TOKEN to token, + AuthCookie.IS_AUTHENTICATED to "true", + AuthCookie.AUTH_TYPE to "password", + ) + cookieJar.saveFromResponse(url, values.map { (cookie, value) -> + Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry) + .apply { + if (url.isHttps) secure() + if (cookie.httpOnly) httpOnly() + }.build() + }) + } } } + fun loadCookieHeader(url: String): String? { + val httpUrl = url.toHttpUrlOrNull() ?: return null + return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() } + ?.joinToString("; ") { "${it.name}=${it.value}" } + } + + fun getAuthHeaders(url: String): Map { + val result = mutableMapOf() + headers.forEach { (key, value) -> result[key] = value } + loadCookieHeader(url)?.let { result["Cookie"] = it } + url.toHttpUrlOrNull()?.let { httpUrl -> + if (httpUrl.username.isNotEmpty()) { + result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password) + } + } + return result + } + + fun rebuildCronetEngine(): CronetEngine { + val old = cronetEngine!! + cronetEngine = buildCronetEngine() + return old + } + + val cronetStoragePath: File get() = cronetStorageDir + + @OptIn(UnstableApi::class) + fun createDataSourceFactory(headers: Map): DataSource.Factory { + return if (isMtls) { + OkHttpDataSource.Factory(client.newBuilder().cache(null).build()) + } else { + ResolvingDataSource.Factory( + CronetDataSource.Factory(cronetEngine!!, cronetExecutor) + ) { dataSpec -> + val newHeaders = dataSpec.httpRequestHeaders.toMutableMap() + newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString())) + newHeaders["Cache-Control"] = "no-store" + dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build() + } + } + } + + private fun buildCronetEngine(): CronetEngine { + return CronetEngine.Builder(appContext) + .enableHttp2(true) + .enableQuic(true) + .enableBrotli(true) + .setStoragePath(cronetStorageDir.absolutePath) + .setUserAgent(USER_AGENT) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES) + .build() + } + private fun build(cacheDir: File): OkHttpClient { val connectionPool = ConnectionPool( maxIdleConnections = KEEP_ALIVE_CONNECTIONS, @@ -188,6 +328,7 @@ object HttpClientManager { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) return OkHttpClient.Builder() + .cookieJar(cookieJar) .addInterceptor { val request = it.request() val builder = request.newBuilder() @@ -249,4 +390,131 @@ object HttpClientManager { socket: Socket? ): String? = null } + + /** + * Persistent CookieJar that duplicates auth cookies across equivalent server URLs. + * When the server sets cookies for one domain, copies are created for all other known + * server domains (for URL switching between local/remote endpoints of the same server). + */ + private class PersistentCookieJar : CookieJar { + private val store = mutableListOf() + private var serverUrls = listOf() + private var prefs: SharedPreferences? = null + + + fun init(prefs: SharedPreferences) { + this.prefs = prefs + restore() + } + + @Synchronized + fun setServerUrls(urls: List) { + val parsed = urls.mapNotNull { it.toHttpUrlOrNull() } + if (parsed.map { it.host } == serverUrls.map { it.host }) return + serverUrls = parsed + if (syncAuthCookies()) persist() + } + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val changed = cookies.any { new -> + store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value } + } + store.removeAll { existing -> + cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path } + } + store.addAll(cookies) + val synced = serverUrls.any { it.host == url.host } && syncAuthCookies() + if (changed || synced) persist() + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + val now = System.currentTimeMillis() + if (store.removeAll { it.expiresAt < now }) { + syncAuthCookies() + persist() + } + return store.filter { it.matches(url) } + } + + private fun syncAuthCookies(): Boolean { + val serverHosts = serverUrls.map { it.host }.toSet() + val now = System.currentTimeMillis() + val sourceCookies = store + .filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now } + .associateBy { it.name } + + if (sourceCookies.isEmpty()) { + return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts } + } + + var changed = false + for (url in serverUrls) { + for ((_, source) in sourceCookies) { + if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue + store.removeAll { it.name == source.name && it.domain == url.host } + store.add(rebuildCookie(source, url)) + changed = true + } + } + return changed + } + + private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie { + return Cookie.Builder() + .name(source.name).value(source.value) + .domain(url.host).path("/") + .expiresAt(source.expiresAt) + .apply { + if (url.isHttps) secure() + if (source.httpOnly) httpOnly() + } + .build() + } + + private fun persist() { + val p = prefs ?: return + p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) } + } + + private fun restore() { + val p = prefs ?: return + val jsonStr = p.getString(PREFS_COOKIES, null) ?: return + try { + store.addAll(Json.decodeFromString>(jsonStr).map { it.toCookie() }) + } catch (_: Exception) { + store.clear() + } + } + } + + @Serializable + private data class SerializedCookie( + val name: String, + val value: String, + val domain: String, + val path: String, + val expiresAt: Long, + val secure: Boolean, + val httpOnly: Boolean, + val hostOnly: Boolean, + ) { + fun toCookie(): Cookie = Cookie.Builder() + .name(name).value(value).path(path).expiresAt(expiresAt) + .apply { + if (hostOnly) hostOnlyDomain(domain) else domain(domain) + if (secure) secure() + if (httpOnly) httpOnly() + } + .build() + + companion object { + fun from(cookie: Cookie) = SerializedCookie( + name = cookie.name, value = cookie.value, domain = cookie.domain, + path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure, + httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly, + ) + } + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt index 5e48d7fef5..869e312515 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -184,7 +184,7 @@ interface NetworkApi { fun removeCertificate(callback: (Result) -> Unit) fun hasCertificate(): Boolean fun getClientPointer(): Long - fun setRequestHeaders(headers: Map, serverUrls: List) + fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) companion object { /** The codec used by NetworkApi. */ @@ -287,8 +287,9 @@ interface NetworkApi { val args = message as List val headersArg = args[0] as Map val serverUrlsArg = args[1] as List + val tokenArg = args[2] as String? val wrapped: List = try { - api.setRequestHeaders(headersArg, serverUrlsArg) + api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg) listOf(null) } catch (exception: Throwable) { NetworkPigeonUtils.wrapError(exception) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt index 384c94cce9..85b7a6c730 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -39,7 +39,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { } } -private class NetworkApiImpl() : NetworkApi { +private class NetworkApiImpl : NetworkApi { var activity: Activity? = null override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { @@ -79,7 +79,7 @@ private class NetworkApiImpl() : NetworkApi { return HttpClientManager.getClientPointer() } - override fun setRequestHeaders(headers: Map, serverUrls: List) { - HttpClientManager.setRequestHeaders(headers, serverUrls) + override fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) { + HttpClientManager.setRequestHeaders(headers, serverUrls, token) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 21e3c603e6..8e9fc3f6d5 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeByteBuffer import app.alextran.immich.core.HttpClientManager -import app.alextran.immich.core.USER_AGENT import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Call @@ -15,9 +14,6 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.Credentials -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest import org.chromium.net.UrlResponseInfo @@ -31,10 +27,6 @@ import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors - - -private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 private class RemoteRequest(val cancellationSignal: CancellationSignal) @@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { } private object ImageFetcherManager { - private lateinit var appContext: Context private lateinit var cacheDir: File private lateinit var fetcher: ImageFetcher private var initialized = false @@ -110,7 +101,6 @@ private object ImageFetcherManager { if (initialized) return synchronized(this) { if (initialized) return - appContext = context.applicationContext cacheDir = context.cacheDir fetcher = build() HttpClientManager.addClientChangedListener(::invalidate) @@ -143,7 +133,7 @@ private object ImageFetcherManager { return if (HttpClientManager.isMtls) { OkHttpImageFetcher.create(cacheDir) } else { - CronetImageFetcher(appContext, cacheDir) + CronetImageFetcher() } } } @@ -161,19 +151,11 @@ private sealed interface ImageFetcher { fun clearCache(onCleared: (Result) -> Unit) } -private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { - private val ctx = context - private var engine: CronetEngine - private val executor = Executors.newFixedThreadPool(4) +private class CronetImageFetcher : ImageFetcher { private val stateLock = Any() private var activeCount = 0 private var draining = false private var onCacheCleared: ((Result) -> Unit)? = null - private val storageDir = File(cacheDir, "cronet").apply { mkdirs() } - - init { - engine = build(context) - } override fun fetch( url: String, @@ -190,29 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } val callback = FetchCallback(onSuccess, onFailure, ::onComplete) - val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) - HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } - url.toHttpUrlOrNull()?.let { httpUrl -> - if (httpUrl.username.isNotEmpty()) { - requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) - } + val requestBuilder = HttpClientManager.cronetEngine!! + .newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor) + HttpClientManager.getAuthHeaders(url).forEach { (key, value) -> + requestBuilder.addHeader(key, value) } val request = requestBuilder.build() signal.setOnCancelListener(request::cancel) request.start() } - private fun build(ctx: Context): CronetEngine { - return CronetEngine.Builder(ctx) - .enableHttp2(true) - .enableQuic(true) - .enableBrotli(true) - .setStoragePath(storageDir.absolutePath) - .setUserAgent(USER_AGENT) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES) - .build() - } - private fun onComplete() { val didDrain = synchronized(stateLock) { activeCount-- @@ -235,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } private fun onDrained() { - engine.shutdown() val onCacheCleared = synchronized(stateLock) { val onCacheCleared = onCacheCleared this.onCacheCleared = null onCacheCleared } - if (onCacheCleared == null) { - executor.shutdown() - } else { + if (onCacheCleared != null) { + val oldEngine = HttpClientManager.rebuildCronetEngine() + oldEngine.shutdown() CoroutineScope(Dispatchers.IO).launch { - val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) } - // Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result - engine = build(ctx) + val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) } synchronized(stateLock) { draining = false } onCacheCleared(result) } @@ -374,7 +340,7 @@ private class OkHttpImageFetcher private constructor( val dir = File(cacheDir, "okhttp") val client = HttpClientManager.getClient().newBuilder() - .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + .cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES)) .build() return OkHttpImageFetcher(client) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index f842285b23..81af41ab08 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import BackgroundTasks import Flutter +import native_video_player import network_info_plus import path_provider_foundation import permission_handler_apple @@ -18,6 +19,8 @@ import UIKit UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } + SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage + URLSessionManager.patchBackgroundDownloader() GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift index 96294c1cd4..5a8075f91a 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -225,7 +225,7 @@ protocol NetworkApi { func removeCertificate(completion: @escaping (Result) -> Void) func hasCertificate() throws -> Bool func getClientPointer() throws -> Int64 - func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws + func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -315,8 +315,9 @@ class NetworkApiSetup { let args = message as! [Any?] let headersArg = args[0] as! [String: String] let serverUrlsArg = args[1] as! [String] + let tokenArg: String? = nilOrValue(args[2]) do { - try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg) + try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg) reply(wrapResult(nil)) } catch { reply(wrapError(error)) diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index 480286b2af..3c4be8e718 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -58,42 +58,39 @@ class NetworkApiImpl: NetworkApi { return Int64(Int(bitPattern: pointer)) } - func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws { - var headers = headers - if let token = headers.removeValue(forKey: "x-immich-user-token") { + func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws { + URLSessionManager.setServerUrls(serverUrls) + + if let token = token { + let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60) for serverUrl in serverUrls { guard let url = URL(string: serverUrl), let domain = url.host else { continue } let isSecure = serverUrl.hasPrefix("https") - let cookies: [(String, String, Bool)] = [ - ("immich_access_token", token, true), - ("immich_is_authenticated", "true", false), - ("immich_auth_type", "password", true), + let values: [AuthCookie: String] = [ + .accessToken: token, + .isAuthenticated: "true", + .authType: "password", ] - let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) - for (name, value, httpOnly) in cookies { + for (cookie, value) in values { var properties: [HTTPCookiePropertyKey: Any] = [ - .name: name, + .name: cookie.name, .value: value, .domain: domain, .path: "/", .expires: expiry, ] if isSecure { properties[.secure] = "TRUE" } - if httpOnly { properties[.init("HttpOnly")] = "TRUE" } - if let cookie = HTTPCookie(properties: properties) { - URLSessionManager.cookieStorage.setCookie(cookie) + if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" } + if let httpCookie = HTTPCookie(properties: properties) { + URLSessionManager.cookieStorage.setCookie(httpCookie) } } } } - if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) { - UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY) - } - if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { UserDefaults.group.set(headers, forKey: HEADERS_KEY) - URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart + URLSessionManager.shared.recreateSession() } } } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 411b828ea1..0b73ed71a6 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -3,8 +3,30 @@ import native_video_player let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let HEADERS_KEY = "immich.request_headers" -let SERVER_URL_KEY = "immich.server_url" +let SERVER_URLS_KEY = "immich.server_urls" let APP_GROUP = "group.app.immich.share" +let COOKIE_EXPIRY_DAYS: TimeInterval = 400 + +enum AuthCookie: CaseIterable { + case accessToken, isAuthenticated, authType + + var name: String { + switch self { + case .accessToken: return "immich_access_token" + case .isAuthenticated: return "immich_is_authenticated" + case .authType: return "immich_auth_type" + } + } + + var httpOnly: Bool { + switch self { + case .accessToken, .authType: return true + case .isAuthenticated: return false + } + } + + static let names: Set = Set(allCases.map(\.name)) +} extension UserDefaults { static let group = UserDefaults(suiteName: APP_GROUP)! @@ -29,26 +51,99 @@ class URLSessionManager: NSObject { diskCapacity: 1024 * 1024 * 1024, directory: cacheDir ) - private static let userAgent: String = { + static let userAgent: String = { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" return "Immich_iOS_\(version)" }() static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) - + private static var serverUrls: [String] = [] + private static var isSyncing = false + var sessionPointer: UnsafeMutableRawPointer { Unmanaged.passUnretained(session).toOpaque() } - + private override init() { delegate = URLSessionManagerDelegate() session = Self.buildSession(delegate: delegate) super.init() + Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? [] + NotificationCenter.default.addObserver( + Self.self, + selector: #selector(Self.cookiesDidChange), + name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged, + object: Self.cookieStorage + ) } func recreateSession() { session = Self.buildSession(delegate: delegate) } + static func setServerUrls(_ urls: [String]) { + guard urls != serverUrls else { return } + serverUrls = urls + UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY) + syncAuthCookies() + } + + @objc private static func cookiesDidChange(_ notification: Notification) { + guard !isSyncing, !serverUrls.isEmpty else { return } + syncAuthCookies() + } + + private static func syncAuthCookies() { + let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host }) + let allCookies = cookieStorage.cookies ?? [] + let now = Date() + + let serverAuthCookies = allCookies.filter { + AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain) + } + + var sourceCookies: [String: HTTPCookie] = [:] + for cookie in serverAuthCookies { + if cookie.expiresDate.map({ $0 > now }) ?? true { + sourceCookies[cookie.name] = cookie + } + } + + isSyncing = true + defer { isSyncing = false } + + if sourceCookies.isEmpty { + for cookie in serverAuthCookies { + cookieStorage.deleteCookie(cookie) + } + return + } + + for serverUrl in serverUrls { + guard let url = URL(string: serverUrl), let domain = url.host else { continue } + let isSecure = serverUrl.hasPrefix("https") + + for (_, source) in sourceCookies { + if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) { + continue + } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: source.name, + .value: source.value, + .domain: domain, + .path: "/", + .expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60), + ] + if isSecure { properties[.secure] = "TRUE" } + if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" } + + if let cookie = HTTPCookie(properties: properties) { + cookieStorage.setCookie(cookie) + } + } + } + } + private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { let config = URLSessionConfiguration.default config.urlCache = urlCache @@ -63,6 +158,49 @@ class URLSessionManager: NSObject { return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } + + /// Patches background_downloader's URLSession to use shared auth configuration. + /// Must be called before background_downloader creates its session (i.e. early in app startup). + static func patchBackgroundDownloader() { + // Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config + let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:") + let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:)) + if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel), + let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) { + method_exchangeImplementations(original, swizzled) + } + + // Add auth challenge handling to background_downloader's UrlSessionDelegate + guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return } + + let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(sessionBlock), "v@:@@@?") + + let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, task, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(taskBlock), "v@:@@@@?") + } +} + +private extension URLSessionConfiguration { + @objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration { + // After swizzle, this calls the original implementation + let config = immich_background(withIdentifier: id) + config.httpCookieStorage = URLSessionManager.cookieStorage + config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent] + return config + } } class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { @@ -73,7 +211,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler) } - + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -82,7 +220,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler, task: task) } - + func handleChallenge( _ session: URLSession, _ challenge: URLAuthenticationChallenge, @@ -95,7 +233,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb default: completionHandler(.performDefaultHandling, nil) } } - + private func handleClientCertificate( _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void @@ -105,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb kSecAttrLabel as String: CLIENT_CERT_LABEL, kSecReturnRef as String: true, ] - + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecSuccess, let identity = item { @@ -119,7 +257,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb } completion(.performDefaultHandling, nil) } - + private func handleBasicAuth( _ session: URLSession, task: URLSessionTask?, diff --git a/mobile/lib/constants/colors.dart b/mobile/lib/constants/colors.dart index 069ed519cf..e39480de32 100644 --- a/mobile/lib/constants/colors.dart +++ b/mobile/lib/constants/colors.dart @@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); -const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); +const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75); const Color red400 = Color(0xFFEF5350); const Color grey200 = Color(0xFFEEEEEE); diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 8bad6fbab0..f28ee3d72d 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -46,6 +46,7 @@ sealed class BaseAsset { bool get isVideo => type == AssetType.video; bool get isMotionPhoto => livePhotoVideoId != null; + bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated; AssetPlaybackStyle get playbackStyle { if (isVideo) return AssetPlaybackStyle.video; diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart index adf1ee5694..bb5796e220 100644 --- a/mobile/lib/infrastructure/repositories/network.repository.dart +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -26,8 +26,8 @@ class NetworkRepository { } } - static Future setHeaders(Map headers, List serverUrls) async { - await networkApi.setRequestHeaders(headers, serverUrls); + static Future setHeaders(Map headers, List serverUrls, {String? token}) async { + await networkApi.setRequestHeaders(headers, serverUrls, token); if (Platform.isIOS) { await init(); } diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index c5084c0236..3ba3389eea 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -148,10 +148,12 @@ class _DriftBackupPageState extends ConsumerState { children: [ Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1), const SizedBox(width: 8), - Text( - context.t.backup_error_sync_failed, - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), - textAlign: TextAlign.center, + Flexible( + child: Text( + context.t.backup_error_sync_failed, + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), + textAlign: TextAlign.center, + ), ), ], ), @@ -344,6 +346,7 @@ class _RemainderCard extends ConsumerWidget { remainderCount.toString(), style: context.textTheme.titleLarge?.copyWith( color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255), + fontFeatures: [const FontFeature.tabularFigures()], ), ), if (syncStatus.isRemoteSyncing) @@ -483,6 +486,7 @@ class _PreparingStatusState extends ConsumerState { style: context.textTheme.titleMedium?.copyWith( color: context.colorScheme.primary, fontWeight: FontWeight.w600, + fontFeatures: [const FontFeature.tabularFigures()], ), ), ], @@ -507,6 +511,7 @@ class _PreparingStatusState extends ConsumerState { style: context.textTheme.titleMedium?.copyWith( color: context.primaryColor, fontWeight: FontWeight.w600, + fontFeatures: [const FontFeature.tabularFigures()], ), ), ], diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 314a943f7d..0ecbb430d3 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -281,7 +281,7 @@ class NetworkApi { } } - Future setRequestHeaders(Map headers, List serverUrls) async { + Future setRequestHeaders(Map headers, List serverUrls, String? token) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -289,7 +289,7 @@ class NetworkApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls, token]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); diff --git a/mobile/lib/presentation/pages/drift_edit.page.dart b/mobile/lib/presentation/pages/drift_edit.page.dart index 361bd9372d..390b0e79b9 100644 --- a/mobile/lib/presentation/pages/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/drift_edit.page.dart @@ -293,7 +293,7 @@ class _DriftEditImagePageState extends ConsumerState with Ti ), backgroundColor: Colors.black, body: SafeArea( - bottom: false, + bottom: true, child: Column( children: [ Expanded( 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 abb7b779fe..0934536471 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; @@ -248,11 +247,6 @@ class _AssetPageState extends ConsumerState { if (scaleState != PhotoViewScaleState.initial) { if (_dragStart == null) _viewer.setControls(false); - - final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag; - if (heroTag != null) { - ref.read(videoPlayerProvider(heroTag).notifier).pause(); - } return; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 0228787ed2..a28c66a554 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget { ), ), child: Container( - color: Colors.black.withAlpha(125), - padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), - if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), - ], + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black45, Colors.black12, Colors.transparent], + stops: [0.0, 0.7, 1.0], + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), + if (!isReadonlyModeEnabled) + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ], + ), + ), ), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index ecfe0b3ddc..9285c01c41 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; @@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg final source = await _videoSource; if (source == null || !mounted) return; - unawaited( - nc.loadVideoSource(source).catchError((error) { - _log.severe('Error loading video source: $error'); - }), - ); + await _notifier.load(source); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); await _notifier.setVolume(1); @@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState with Widg @override Widget build(BuildContext context) { - // Prevent the provider from being disposed whilst the widget is alive. - ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {}); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status)); - return Stack( - children: [ - Center(child: widget.image), - if (!isCasting) - Visibility.maintain( - visible: _isVideoReady, - child: NativeVideoPlayerView(onViewReady: _initController), - ), - if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)), - ], + return IgnorePointer( + child: Stack( + children: [ + Center(child: widget.image), + if (!isCasting) ...[ + Visibility.maintain( + visible: _isVideoReady, + child: NativeVideoPlayerView(onViewReady: _initController), + ), + Center( + child: AnimatedOpacity( + opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0, + duration: const Duration(milliseconds: 400), + child: const CircularProgressIndicator(), + ), + ), + ], + ], + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart deleted file mode 100644 index e079f666ec..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/models/cast/cast_manager_state.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/utils/hooks/timer_hook.dart'; -import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; - -class VideoViewerControls extends HookConsumerWidget { - final BaseAsset asset; - final Duration hideTimerDuration; - - const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final videoPlayerName = asset.heroTag; - final assetIsVideo = asset.isVideo; - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails)); - final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status)); - - final cast = ref.watch(castProvider); - - // A timer to hide the controls - final hideTimer = useTimer(hideTimerDuration, () { - if (!context.mounted) { - return; - } - final status = ref.read(videoPlayerProvider(videoPlayerName)).status; - - // Do not hide on paused - if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - }); - final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; - - /// Shows the controls and starts the timer to hide them - void showControlsAndStartHideTimer() { - hideTimer.reset(); - ref.read(assetViewerProvider.notifier).setControls(true); - } - - // When playback starts, reset the hide timer - ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) { - if (next == VideoPlaybackStatus.playing) { - hideTimer.reset(); - } - }); - - /// Toggles between playing and pausing depending on the state of the video - void togglePlay() { - showControlsAndStartHideTimer(); - - if (cast.isCasting) { - switch (cast.castState) { - case CastState.playing: - ref.read(castProvider.notifier).pause(); - case CastState.paused: - ref.read(castProvider.notifier).play(); - default: - } - return; - } - - final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier); - switch (status) { - case VideoPlaybackStatus.playing: - notifier.pause(); - case VideoPlaybackStatus.completed: - notifier.restart(); - default: - notifier.play(); - } - } - - void toggleControlsVisibility() { - if (showBuffering) return; - - if (showControls) { - ref.read(assetViewerProvider.notifier).setControls(false); - } else { - showControlsAndStartHideTimer(); - } - } - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: toggleControlsVisibility, - child: IgnorePointer( - ignoring: !showControls, - child: Stack( - children: [ - if (showBuffering) - const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400))) - else - CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: status == VideoPlaybackStatus.completed, - isPlaying: - status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), - show: assetIsVideo && showControls, - onPressed: togglePlay, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 4ba4152a8d..ae7dd85396 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -75,17 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { child: AnimatedOpacity( opacity: opacity, duration: Durations.short2, - child: AppBar( - backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5), - leading: const _AppBarBackButton(), - iconTheme: const IconThemeData(size: 22, color: Colors.white), - actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), - shape: const Border(), - actions: showingDetails || isReadonlyModeEnabled - ? null - : isInLockedView - ? lockedViewActions - : actions, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: showingDetails + ? null + : const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black45, Colors.black12, Colors.transparent], + stops: [0.0, 0.7, 1.0], + ), + ), + child: AppBar( + backgroundColor: Colors.transparent, + leading: const _AppBarBackButton(), + iconTheme: const IconThemeData(size: 22, color: Colors.white), + actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), + shape: const Border(), + actions: showingDetails || isReadonlyModeEnabled + ? null + : isInLockedView + ? lockedViewActions + : actions, + ), ), ), ); @@ -101,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; - return Padding( padding: const EdgeInsets.only(left: 12.0), child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: backgroundColor, + backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent, shape: const CircleBorder(), iconSize: 22, - iconColor: foregroundColor, + iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white, padding: EdgeInsets.zero, elevation: showingDetails ? 4 : 0, ), diff --git a/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart b/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart new file mode 100644 index 0000000000..be4fbff8cf --- /dev/null +++ b/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart' show InformationCollector; +import 'package:flutter/painting.dart'; + +/// A [MultiFrameImageStreamCompleter] with support for listener tracking +/// which makes resource cleanup possible when no longer needed. +/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method +class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter { + void Function()? _onLastListenerRemoved; + int _listenerCount = 0; + // True once any image or the codec has been provided. + // Until then the image cache holds one listener, so "last real listener gone" + // is _listenerCount == 1, not 0. + bool didProvideImage = false; + + AnimatedImageStreamCompleter._({ + required super.codec, + required super.scale, + super.informationCollector, + void Function()? onLastListenerRemoved, + }) : _onLastListenerRemoved = onLastListenerRemoved; + + factory AnimatedImageStreamCompleter({ + required Stream stream, + required double scale, + ImageInfo? initialImage, + InformationCollector? informationCollector, + void Function()? onLastListenerRemoved, + }) { + final codecCompleter = Completer(); + final self = AnimatedImageStreamCompleter._( + codec: codecCompleter.future, + scale: scale, + informationCollector: informationCollector, + onLastListenerRemoved: onLastListenerRemoved, + ); + + if (initialImage != null) { + self.didProvideImage = true; + self.setImage(initialImage); + } + + stream.listen( + (item) { + if (item is ImageInfo) { + self.didProvideImage = true; + self.setImage(item); + } else if (item is ui.Codec) { + if (!codecCompleter.isCompleted) { + self.didProvideImage = true; + codecCompleter.complete(item); + } + } + }, + onError: (Object error, StackTrace stack) { + if (!codecCompleter.isCompleted) { + codecCompleter.completeError(error, stack); + } + }, + onDone: () { + // also complete if we are done but no error occurred, and we didn't call complete yet + // could happen on cancellation + if (!codecCompleter.isCompleted) { + codecCompleter.completeError(StateError('Stream closed without providing a codec')); + } + }, + ); + + return self; + } + + @override + void addListener(ImageStreamListener listener) { + super.addListener(listener); + _listenerCount++; + } + + @override + void removeListener(ImageStreamListener listener) { + super.removeListener(listener); + _listenerCount--; + + final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage; + final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage; + + if (onlyCacheListenerLeft || noListenersAfterCodec) { + final onLastListenerRemoved = _onLastListenerRemoved; + if (onLastListenerRemoved != null) { + _onLastListenerRemoved = null; + onLastListenerRemoved(); + } + } + } +} diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index c5cb1eff20..7a2ac7b5da 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 final ImageProvider provider; if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type); + provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); } else { final String assetId; final String thumbhash; @@ -153,7 +153,13 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 } else { throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); } - provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type, edited: edited); + provider = RemoteFullImageProvider( + assetId: assetId, + thumbhash: thumbhash, + assetType: asset.type, + isAnimated: asset.isAnimatedImage, + edited: edited, + ); } return provider; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 1c7d102239..1ed2c361ff 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,11 +1,10 @@ -import 'dart:ui'; - import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; @@ -58,8 +57,9 @@ class LocalFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Id', key.id), + DiagnosticsProperty('Size', key.size), + DiagnosticsProperty('isAnimated', key.isAnimated), + ], + onLastListenerRemoved: cancel, + ); + } + return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), @@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider('Image provider', this), DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), + DiagnosticsProperty('isAnimated', key.isAnimated), ], onLastListenerRemoved: cancel, ); @@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { + yield* initialImageStream(); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; + final previewRequest = request = LocalImageRequest( + localId: key.id, + size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), + assetType: key.assetType, + ); + yield* loadRequest(previewRequest, decode); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + // always try original for animated, since previews don't support animation + final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType); + final codec = await loadCodecRequest(originalRequest); + if (codec == null) { + throw StateError('Failed to load animated codec for local asset ${key.id}'); + } + yield codec; + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalFullImageProvider) { - return id == other.id && size == other.size; + return id == other.id && size == other.size && isAnimated == other.isAnimated; } return false; } @override - int get hashCode => id.hashCode ^ size.hashCode; + int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index dcb8da0817..7f2e372ee8 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; @@ -58,12 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('isAnimated', key.isAnimated), + ], + onLastListenerRemoved: cancel, + ); + } + return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), initialImage: getInitialImage( @@ -82,6 +99,7 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('isAnimated', key.isAnimated), ], onLastListenerRemoved: cancel, ); @@ -121,16 +139,46 @@ class RemoteFullImageProvider extends CancellableImageProvider _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* { + yield* initialImageStream(); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + final previewRequest = request = RemoteImageRequest( + uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash), + ); + yield* loadRequest(previewRequest, decode, evictOnError: false); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + // always try original for animated, since previews don't support animation + final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId)); + final codec = await loadCodecRequest(originalRequest); + if (codec == null) { + throw StateError('Failed to load animated codec for asset ${key.assetId}'); + } + yield codec; + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is RemoteFullImageProvider) { - return assetId == other.assetId && thumbhash == other.thumbhash && edited == other.edited; + return assetId == other.assetId && + thumbhash == other.thumbhash && + isAnimated == other.isAnimated && + edited == other.edited; } return false; } @override - int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode ^ edited.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 3593fc75e8..2ceaf80db0 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -305,6 +305,8 @@ class _AssetTypeIcons extends StatelessWidget { padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.motion_photos_on_rounded), ), + if (asset.isAnimatedImage) + const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)), ], ); } diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index 785dfd1e4c..19c92e7c96 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier { return; } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); - if (showing) { - final heroTag = state.currentAsset?.heroTag; - if (heroTag != null) { - ref.read(videoPlayerProvider(heroTag).notifier).pause(); - } + + final heroTag = state.currentAsset?.heroTag; + if (heroTag != null) { + final notifier = ref.read(videoPlayerProvider(heroTag).notifier); + showing ? notifier.hold() : notifier.release(); } } diff --git a/mobile/lib/providers/asset_viewer/video_player_provider.dart b/mobile/lib/providers/asset_viewer/video_player_provider.dart index 0ca3bf4f74..a4a8bd1762 100644 --- a/mobile/lib/providers/asset_viewer/video_player_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_provider.dart @@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier { NativeVideoPlayerController? _controller; Timer? _bufferingTimer; Timer? _seekTimer; - - void attachController(NativeVideoPlayerController controller) { - _controller = controller; - } + VideoPlaybackStatus? _holdStatus; @override void dispose() { @@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier { super.dispose(); } + void attachController(NativeVideoPlayerController controller) { + _controller = controller; + } + + Future load(VideoSource source) async { + _startBufferingTimer(); + try { + await _controller?.loadVideoSource(source); + } catch (e) { + _log.severe('Error loading video source: $e'); + } + } + Future pause() async { if (_controller == null) return; @@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier { } void seekTo(Duration position) { - if (_controller == null) return; + if (_controller == null || state.position == position) return; state = state.copyWith(position: position); - _seekTimer?.cancel(); - _seekTimer = Timer(const Duration(milliseconds: 100), () { - _controller?.seekTo(position.inMilliseconds); + if (_seekTimer?.isActive ?? false) return; + + _seekTimer = Timer(const Duration(milliseconds: 150), () { + _controller?.seekTo(state.position.inMilliseconds); }); } + void toggle() { + _holdStatus = null; + + switch (state.status) { + case VideoPlaybackStatus.paused: + play(); + case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering: + pause(); + case VideoPlaybackStatus.completed: + restart(); + } + } + + /// Pauses playback and preserves the current status for later restoration. + void hold() { + if (_holdStatus != null) return; + + _holdStatus = state.status; + pause(); + } + + /// Restores playback to the status before [hold] was called. + void release() { + final status = _holdStatus; + _holdStatus = null; + + switch (status) { + case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering: + play(); + default: + } + } + Future restart() async { seekTo(Duration.zero); await play(); @@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier { final position = Duration(milliseconds: playbackInfo.position); if (state.position == position) return; - if (state.status == VideoPlaybackStatus.buffering) { - state = state.copyWith(position: position, status: VideoPlaybackStatus.playing); - } else { - state = state.copyWith(position: position); - } + if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer(); - _startBufferingTimer(); + state = state.copyWith( + position: position, + status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null, + ); } void onNativeStatusChanged() { @@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier { onNativePlaybackEnded(); } - if (state.status != newStatus) { - state = state.copyWith(status: newStatus); - } + if (state.status != newStatus) state = state.copyWith(status: newStatus); } void onNativePlaybackEnded() { @@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier { void _startBufferingTimer() { _bufferingTimer?.cancel(); _bufferingTimer = Timer(const Duration(seconds: 3), () { - if (mounted && state.status == VideoPlaybackStatus.playing) { + if (mounted && state.status != VideoPlaybackStatus.completed) { state = state.copyWith(status: VideoPlaybackStatus.buffering); } }); diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index ee3367eef2..825d9e7bc8 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -123,7 +123,7 @@ class AuthNotifier extends StateNotifier { } Future saveAuthInfo({required String accessToken}) async { - await _apiService.setAccessToken(accessToken); + await Store.put(StoreKey.accessToken, accessToken); await _apiService.updateHeaders(); final serverEndpoint = Store.get(StoreKey.serverEndpoint); @@ -145,7 +145,6 @@ class AuthNotifier extends StateNotifier { user = serverUser; await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - await Store.put(StoreKey.accessToken, accessToken); } } on ApiException catch (error, stackTrace) { if (error.code == 401) { diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 1cd5ded487..fea95f42aa 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier { return discovered; } + void toggle() { + switch (state.castState) { + case CastState.playing: + pause(); + case CastState.paused: + play(); + default: + } + } + void play() { _gCastService.play(); } diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index ba978b0df0..a8544ef6c0 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -38,10 +38,6 @@ class AuthRepository extends DatabaseRepository { }); } - String getAccessToken() { - return Store.get(StoreKey.accessToken); - } - bool getEndpointSwitchingFeature() { return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 566ec7aa31..e296ac522d 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -class ApiService implements Authentication { +class ApiService { late ApiClient _apiClient; late UsersApi usersApi; @@ -45,7 +45,6 @@ class ApiService implements Authentication { setEndpoint(endpoint); } } - String? _accessToken; final _log = Logger("ApiService"); Future updateHeaders() async { @@ -54,11 +53,8 @@ class ApiService implements Authentication { } setEndpoint(String endpoint) { - _apiClient = ApiClient(basePath: endpoint, authentication: this); + _apiClient = ApiClient(basePath: endpoint); _apiClient.client = NetworkRepository.client; - if (_accessToken != null) { - setAccessToken(_accessToken!); - } usersApi = UsersApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient); oAuthApi = AuthenticationApi(_apiClient); @@ -157,11 +153,6 @@ class ApiService implements Authentication { return ""; } - Future setAccessToken(String accessToken) async { - _accessToken = accessToken; - await Store.put(StoreKey.accessToken, accessToken); - } - Future setDeviceInfoHeader() async { DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); @@ -185,10 +176,6 @@ class ApiService implements Authentication { if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } - final serverUrl = Store.tryGet(StoreKey.serverUrl); - if (serverUrl != null && serverUrl.isNotEmpty) { - urls.add(serverUrl); - } final localEndpoint = Store.tryGet(StoreKey.localEndpoint); if (localEndpoint != null && localEndpoint.isNotEmpty) { urls.add(localEndpoint); @@ -205,28 +192,12 @@ class ApiService implements Authentication { } static Map getRequestHeaders() { - var accessToken = Store.get(StoreKey.accessToken, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, ""); - var header = {}; - if (accessToken.isNotEmpty) { - header['x-immich-user-token'] = accessToken; - } - if (customHeadersStr.isEmpty) { - return header; + return const {}; } - var customHeaders = jsonDecode(customHeadersStr) as Map; - customHeaders.forEach((key, value) { - header[key] = value; - }); - - return header; - } - - @override - Future applyToParams(List queryParams, Map headerParams) { - return Future.value(); + return (jsonDecode(customHeadersStr) as Map).cast(); } ApiClient get apiClient => _apiClient; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d022d9a5cf..03278d25fc 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -340,7 +340,6 @@ class BackgroundService { ], ); - await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 1e8d426df8..2efd52cc81 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -74,7 +74,6 @@ class BackupVerificationService { final lower = compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates.slice(0, half), originals: originals.slice(0, half), - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -82,7 +81,6 @@ class BackupVerificationService { final upper = compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates.slice(half), originals: originals.slice(half), - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -92,7 +90,6 @@ class BackupVerificationService { toDelete = await compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates, originals: originals, - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -105,7 +102,6 @@ class BackupVerificationService { ({ List deleteCandidates, List originals, - String auth, String endpoint, RootIsolateToken rootIsolateToken, FileMediaRepository fileMediaRepository, @@ -120,7 +116,6 @@ class BackupVerificationService { await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); - await apiService.setAccessToken(tuple.auth); for (int i = 0; i < tuple.deleteCandidates.length; i++) { if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) { result.add(tuple.deleteCandidates[i]); diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart index 3837d6337c..69b8596490 100644 --- a/mobile/lib/theme/theme_data.dart +++ b/mobile/lib/theme/theme_data.dart @@ -62,8 +62,6 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale ), chipTheme: const ChipThemeData(side: BorderSide.none), sliderTheme: const SliderThemeData( - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), - trackHeight: 2.0, // ignore: deprecated_member_use year2023: false, ), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 6b6f1b251b..76916cee1e 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -25,8 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -35,7 +37,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 24; +const int targetVersion = 25; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -109,6 +111,16 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await _applyLocalAssetOrientation(drift); } + if (version < 25) { + final accessToken = Store.tryGet(StoreKey.accessToken); + if (accessToken != null && accessToken.isNotEmpty) { + final serverUrls = ApiService.getServerUrls(); + if (serverUrls.isNotEmpty) { + await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken); + } + } + } + if (version < 22 && !Store.isBetaTimelineEnabled) { await Store.put(StoreKey.needBetaMigration, true); } diff --git a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart index e7ceac6105..4be7f49b5a 100644 --- a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart +++ b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart @@ -1,12 +1,15 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; /// A widget that animates implicitly between a play and a pause icon. class AnimatedPlayPause extends StatefulWidget { - const AnimatedPlayPause({super.key, required this.playing, this.size, this.color}); + const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows}); final double? size; final bool playing; final Color? color; + final List? shadows; @override State createState() => AnimatedPlayPauseState(); @@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State with SingleTickerP @override Widget build(BuildContext context) { + final icon = AnimatedIcon( + color: widget.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ); + return Center( - child: AnimatedIcon( - color: widget.color, - size: widget.size, - icon: AnimatedIcons.play_pause, - progress: animationController, + child: Stack( + alignment: Alignment.center, + children: [ + for (final shadow in widget.shadows ?? const []) + Transform.translate( + offset: shadow.offset, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2), + child: AnimatedIcon( + color: shadow.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ), + ), + ), + icon, + ], ), ); } diff --git a/mobile/lib/widgets/asset_viewer/formatted_duration.dart b/mobile/lib/widgets/asset_viewer/formatted_duration.dart deleted file mode 100644 index fbcc8e6482..0000000000 --- a/mobile/lib/widgets/asset_viewer/formatted_duration.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; - -class FormattedDuration extends StatelessWidget { - final Duration data; - const FormattedDuration(this.data, {super.key}); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter - child: Text( - data.format(), - style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500), - textAlign: TextAlign.center, - ), - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 381388d8d2..85707c82ea 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -1,22 +1,113 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; +import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart'; -/// The video controls for the [videoPlayerProvider] -class VideoControls extends ConsumerWidget { +class VideoControls extends HookConsumerWidget { final String videoPlayerName; + static const List _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))]; + const VideoControls({super.key, required this.videoPlayerName}); + void _toggle(WidgetRef ref, bool isCasting) { + if (isCasting) { + ref.read(castProvider.notifier).toggle(); + } else { + ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle(); + } + } + + void _onSeek(WidgetRef ref, bool isCasting, double value) { + final seekTo = Duration(microseconds: value.toInt()); + + if (isCasting) { + ref.read(castProvider.notifier).seekTo(seekTo); + return; + } + + ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo); + } + @override Widget build(BuildContext context, WidgetRef ref) { - final isPortrait = context.orientation == Orientation.portrait; - return isPortrait - ? VideoPosition(videoPlayerName: videoPlayerName) - : Padding( - padding: const EdgeInsets.symmetric(horizontal: 60.0), - child: VideoPosition(videoPlayerName: videoPlayerName), - ); + final provider = videoPlayerProvider(videoPlayerName); + final cast = ref.watch(castProvider); + final isCasting = cast.isCasting; + + final (position, duration) = isCasting + ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) + : ref.watch(provider.select((v) => (v.position, v.duration))); + + final videoStatus = ref.watch(provider.select((v) => v.status)); + final isPlaying = isCasting + ? cast.castState == CastState.playing + : videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering; + final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed; + + final hideTimer = useTimer(const Duration(seconds: 5), () { + if (!context.mounted) return; + if (ref.read(provider).status == VideoPlaybackStatus.playing) { + ref.read(assetViewerProvider.notifier).setControls(false); + } + }); + + ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset()); + + final notifier = ref.read(provider.notifier); + final isLoaded = duration != Duration.zero; + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + spacing: 16, + children: [ + Row( + children: [ + IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + icon: isFinished + ? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows), + onPressed: () => _toggle(ref, isCasting), + ), + const Spacer(), + Text( + "${position.format()} / ${duration.format()}", + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + shadows: _controlShadows, + ), + ), + const SizedBox(width: 16), + ], + ), + Slider( + value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()), + min: 0, + max: max(duration.inMicroseconds.toDouble(), 1), + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: whiteOpacity75, + padding: EdgeInsets.zero, + onChangeStart: (_) => notifier.hold(), + onChangeEnd: (_) => notifier.release(), + onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null, + ), + ], + ), + ); } } diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart deleted file mode 100644 index cbcbdb88e7..0000000000 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/colors.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; - -class VideoPosition extends HookConsumerWidget { - final String videoPlayerName; - - const VideoPosition({super.key, required this.videoPlayerName}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isCasting = ref.watch(castProvider).isCasting; - - final (position, duration) = isCasting - ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) - : ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration))); - - final wasPlaying = useRef(true); - return duration == Duration.zero - ? const _VideoPositionPlaceholder() - : Column( - children: [ - Padding( - // align with slider's inherent padding - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [FormattedDuration(position), FormattedDuration(duration)], - ), - ), - Row( - children: [ - Expanded( - child: Slider( - value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChangeStart: (value) { - final status = ref.read(videoPlayerProvider(videoPlayerName)).status; - wasPlaying.value = status != VideoPlaybackStatus.paused; - ref.read(videoPlayerProvider(videoPlayerName).notifier).pause(); - }, - onChangeEnd: (value) { - if (wasPlaying.value) { - ref.read(videoPlayerProvider(videoPlayerName).notifier).play(); - } - }, - onChanged: (value) { - final seekToDuration = (duration * (value / 100.0)); - - if (isCasting) { - ref.read(castProvider.notifier).seekTo(seekToDuration); - return; - } - - ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration); - }, - ), - ), - ], - ), - ], - ); - } -} - -class _VideoPositionPlaceholder extends StatelessWidget { - const _VideoPositionPlaceholder(); - - static void _onChangedDummy(_) {} - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)], - ), - ), - Row( - children: [ - Expanded( - child: Slider( - value: 0.0, - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChanged: _onChangedDummy, - ), - ), - ], - ), - ], - ); - } -} diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index 141a2ac7d4..57978e83ff 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -35,7 +35,12 @@ class ImmichImage extends StatelessWidget { } if (asset == null) { - return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video); + return RemoteFullImageProvider( + assetId: assetId!, + thumbhash: '', + assetType: base_asset.AssetType.video, + isAnimated: false, + ); } if (useLocal(asset)) { @@ -43,12 +48,14 @@ class ImmichImage extends StatelessWidget { id: asset.localId!, assetType: base_asset.AssetType.video, size: Size(width, height), + isAnimated: false, ); } else { return RemoteFullImageProvider( assetId: asset.remoteId!, thumbhash: asset.thumbhash ?? '', assetType: base_asset.AssetType.video, + isAnimated: false, ); } } diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 37eeffcf46..084662ace8 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -427,11 +427,7 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { // ignore: prefer_const_declarations final apiPath = r'/shared-links/{id}/assets' .replaceAll('{id}', id); @@ -443,13 +439,6 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - if (slug != null) { - queryParams.addAll(_queryParams('', 'slug', slug)); - } - const contentTypes = ['application/json']; @@ -473,12 +462,8 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, ); + Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async { + final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index 3ea29052d9..704efed770 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -43,5 +43,5 @@ abstract class NetworkApi { int getClientPointer(); - void setRequestHeaders(Map headers, List serverUrls); + void setRequestHeaders(Map headers, List serverUrls, String? token); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index de116abb7e..9d5f431792 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1218,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" - resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" + ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 + resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3a075d67ff..77955c06ab 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,7 +56,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2' + ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d2eb322009..2227273535 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11605,22 +11605,6 @@ "format": "uuid", "type": "string" } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "slug", - "required": false, - "in": "query", - "schema": { - "type": "string" - } } ], "requestBody": { @@ -11677,6 +11661,7 @@ "state": "Stable" } ], + "x-immich-permission": "sharedLink.update", "x-immich-state": "Stable" }, "put": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index cdf2ef19dd..89b48d1d13 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "typescript": "^5.3.3" }, "repository": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5c8ac6dbc1..5a47cf2707 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5987,19 +5987,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { /** * Remove assets from a shared link */ -export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { +export function removeSharedLinkAssets({ id, assetIdsDto }: { id: string; - key?: string; - slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key, - slug - }))}`, oazapfts.json({ + }>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", body: assetIdsDto diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ad290f7a..9d47ba73f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,11 +63,11 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.14 - version: 24.11.0 + specifier: ^24.11.0 + version: 24.12.0 '@vitest/coverage-v8': specifier: ^4.0.0 - version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -91,7 +91,7 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -109,16 +109,16 @@ importers: version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.0 - version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.4.5(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -220,11 +220,11 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.14 - version: 24.11.0 + specifier: ^24.11.0 + version: 24.12.0 '@types/pg': specifier: ^8.15.1 - version: 8.16.0 + version: 8.18.0 '@types/pngjs': specifier: ^6.0.4 version: 6.0.5 @@ -248,16 +248,16 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 luxon: specifier: ^3.4.4 version: 3.7.2 pg: specifier: ^8.11.3 - version: 8.19.0 + version: 8.20.0 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -287,10 +287,10 @@ importers: version: 5.2.1(encoding@0.1.13) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.0 - version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -323,8 +323,8 @@ importers: version: 1.2.0 devDependencies: '@types/node': - specifier: ^24.10.14 - version: 24.11.0 + specifier: ^24.11.0 + version: 24.12.0 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -351,58 +351,58 @@ importers: version: 0.3.2 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1) + version: 11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(bullmq@5.70.4) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/platform-socket.io': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.0.0 - version: 6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 6.1.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/swagger': specifier: ^11.0.2 - version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 '@opentelemetry/context-async-hooks': specifier: ^2.0.0 - version: 2.5.1(@opentelemetry/api@1.9.0) + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-prometheus': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-http': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-ioredis': - specifier: ^0.60.0 - version: 0.60.0(@opentelemetry/api@1.9.0) + specifier: ^0.61.0 + version: 0.61.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-nestjs-core': - specifier: ^0.58.0 - version: 0.58.0(@opentelemetry/api@1.9.0) + specifier: ^0.59.0 + version: 0.59.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-pg': - specifier: ^0.64.0 - version: 0.64.0(@opentelemetry/api@1.9.0) + specifier: ^0.65.0 + version: 0.65.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': specifier: ^2.0.1 - version: 2.5.1(@opentelemetry/api@1.9.0) + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': specifier: ^2.0.1 - version: 2.5.1(@opentelemetry/api@1.9.0) + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: ^1.34.0 version: 1.40.0 @@ -432,7 +432,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.70.1 + version: 5.70.4 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -440,8 +440,8 @@ importers: specifier: ^0.5.1 version: 0.5.1 class-validator: - specifier: ^0.14.0 - version: 0.14.4 + specifier: ^0.15.0 + version: 0.15.1 compression: specifier: ^1.8.0 version: 1.8.1 @@ -456,7 +456,7 @@ importers: version: 4.4.0 exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 express: specifier: ^5.1.0 version: 5.2.1 @@ -477,7 +477,7 @@ importers: version: 7.14.0 ioredis: specifier: ^5.8.2 - version: 5.9.3 + version: 5.10.0 jose: specifier: ^5.10.0 version: 5.10.0 @@ -488,11 +488,11 @@ importers: specifier: ^9.0.2 version: 9.0.3 kysely: - specifier: 0.28.2 - version: 0.28.2 + specifier: 0.28.11 + version: 0.28.11 kysely-postgres-js: specifier: ^3.0.0 - version: 3.0.0(kysely@0.28.2)(postgres@3.4.8) + version: 3.0.0(kysely@0.28.11)(postgres@3.4.8) lodash: specifier: ^4.17.21 version: 4.17.23 @@ -507,16 +507,16 @@ importers: version: 2.1.1 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@types/inquirer@8.2.12)(@types/node@24.12.0)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 - version: 5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 5.4.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(kysely@0.28.11)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^7.0.0 - version: 7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 7.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) nodemailer: specifier: ^7.0.0 version: 7.0.13 @@ -525,10 +525,10 @@ importers: version: 6.8.2 pg: specifier: ^8.11.3 - version: 8.19.0 + version: 8.20.0 pg-connection-string: specifier: ^2.9.1 - version: 2.11.0 + version: 2.12.0 picomatch: specifier: ^4.0.2 version: 4.0.3 @@ -592,16 +592,16 @@ importers: version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(@types/node@24.11.0) + version: 11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) '@swc/core': specifier: ^1.4.14 - version: 1.15.13(@swc/helpers@0.5.17) + version: 1.15.18(@swc/helpers@0.5.17) '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -643,10 +643,10 @@ importers: version: 4.13.4 '@types/multer': specifier: ^2.0.0 - version: 2.0.0 + version: 2.1.0 '@types/node': - specifier: ^24.10.14 - version: 24.11.0 + specifier: ^24.11.0 + version: 24.12.0 '@types/nodemailer': specifier: ^7.0.0 version: 7.0.11 @@ -661,7 +661,7 @@ importers: version: 19.2.14 '@types/sanitize-html': specifier: ^2.13.0 - version: 2.16.0 + version: 2.16.1 '@types/semver': specifier: ^7.5.8 version: 7.7.1 @@ -676,7 +676,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^10.0.0 version: 10.0.2(jiti@2.6.1) @@ -691,7 +691,7 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -727,13 +727,13 @@ importers: version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 - version: 1.5.9(@swc/core@1.15.13(@swc/helpers@0.5.17))(rollup@4.55.1) + version: 1.5.9(@swc/core@1.15.18(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -748,7 +748,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + version: 0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -781,7 +781,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.53.5) + version: 0.3.9(svelte@5.53.7) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -799,7 +799,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.7.0 + version: 20.8.3 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -832,19 +832,25 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.53.5) + version: 4.0.1(svelte@5.53.7) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.53.5) + version: 3.11.0(svelte@5.53.7) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.53.5) + version: 1.2.6(svelte@5.53.7) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.53.5) + version: 0.12.0(svelte@5.53.7) tabbable: specifier: ^6.2.0 version: 6.4.0 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -869,25 +875,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 - version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -911,7 +917,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^4.0.0 - version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.3.1 @@ -926,7 +932,7 @@ importers: version: 6.2.1(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.5) + version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7) eslint-plugin-unicorn: specifier: ^63.0.0 version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) @@ -935,7 +941,7 @@ importers: version: 1.4.2 globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 prettier: specifier: ^3.7.4 version: 3.8.1 @@ -947,19 +953,19 @@ importers: version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.5.0(prettier@3.8.1)(svelte@5.53.5) + version: 3.5.1(prettier@3.8.1)(svelte@5.53.7) rollup-plugin-visualizer: specifier: ^6.0.0 - version: 6.0.5(rollup@4.55.1) + version: 6.0.11(rollup@4.55.1) svelte: - specifier: 5.53.5 - version: 5.53.5 + specifier: 5.53.7 + version: 5.53.7 svelte-check: specifier: ^4.1.5 - version: 4.4.3(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3) + version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.5.1(svelte@5.53.5) + version: 1.6.0(svelte@5.53.7) tailwindcss: specifier: ^4.1.7 version: 4.2.1 @@ -971,10 +977,10 @@ importers: version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.0 - version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -1228,8 +1234,8 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true @@ -1691,8 +1697,8 @@ packages: resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} '@balena/dockerignore@1.0.2': @@ -3183,6 +3189,9 @@ packages: '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3445,8 +3454,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.14': - resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} + '@nestjs/common@11.1.16': + resolution: {integrity: sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -3458,8 +3467,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.14': - resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} + '@nestjs/core@11.1.16': + resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -3489,14 +3498,14 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.14': - resolution: {integrity: sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==} + '@nestjs/platform-express@11.1.16': + resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-socket.io@11.1.14': - resolution: {integrity: sha512-LLSIWkYz4FcvUhfepillYQboo9qbjq1YtQj8XC3zyex+EaqNXvxhZntx/1uJhAjc655pJts9HfZwWXei8jrRGw==} + '@nestjs/platform-socket.io@11.1.16': + resolution: {integrity: sha512-3fYQTi8F2hb7HDkes/ArGhY8lkjB/Df29F5CN4cjbk4cmfpRVy89p6N1BC7PjVOHMAzdwqeX8FabqspdSAnywA==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -3530,8 +3539,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.14': - resolution: {integrity: sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==} + '@nestjs/testing@11.1.16': + resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3543,8 +3552,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.1.14': - resolution: {integrity: sha512-fVP6RmmrmtLIitTXN9er7BUOIjjxcdIewN/zUtBlwgfng+qKBTxpNFOs3AXXbCu8bQr2xjzhjrBTfqri0Ske7w==} + '@nestjs/websockets@11.1.16': + resolution: {integrity: sha512-kfLhCFsq6139JVFCQpbFB6LOEjZzdpE7JzXsZtRbVjqmsgTKVSIh8gKRgzpcq27rbLNqHhhZavboOltOfSxZow==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3587,94 +3596,94 @@ packages: '@oazapfts/runtime@1.2.0': resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==} - '@opentelemetry/api-logs@0.212.0': - resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} engines: {node: '>=8.0.0'} '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/configuration@0.212.0': - resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + '@opentelemetry/configuration@0.213.0': + resolution: {integrity: sha512-MfVgZiUuwL1d3bPPvXcEkVHGTGNUGoqGK97lfwBuRoKttcVGGqDyxTCCVa5MGbirtBQkUTysXMBUVWPaq7zbWw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks@2.5.1': - resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + '@opentelemetry/context-async-hooks@2.6.0': + resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.5.1': - resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': - resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0': + resolution: {integrity: sha512-QiRZzvayEOFnenSXi85Eorgy5WTqyNQ+E7gjl6P6r+W3IUIwAIH8A9/BgMWfP056LwmdrBL6+qvnwaIEmug6Yg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-http@0.212.0': - resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + '@opentelemetry/exporter-logs-otlp-http@0.213.0': + resolution: {integrity: sha512-vqDVSpLp09ZzcFIdb7QZrEFPxUlO3GzdhBKLstq3jhYB5ow3+ZtV5V0ngSdi/0BZs+J5WPiN1+UDV4X5zD/GzA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-proto@0.212.0': - resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + '@opentelemetry/exporter-logs-otlp-proto@0.213.0': + resolution: {integrity: sha512-gQk41nqfK3KhDk8jbSo3LR/fQBlV7f6Q5xRcfDmL1hZlbgXQPdVFV9/rIfYUrCoq1OM+2NnKnFfGjBt6QpLSsA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': - resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0': + resolution: {integrity: sha512-Z8gYKUAU48qwm+a1tjnGv9xbE7a5lukVIwgF6Z5i3VPXPVMe4Sjra0nN3zU7m277h+V+ZpsPGZJ2Xf0OTkL7/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-http@0.212.0': - resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': - resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.212.0': - resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + '@opentelemetry/exporter-prometheus@0.213.0': + resolution: {integrity: sha512-FyV3/JfKGAgx+zJUwCHdjQHbs+YeGd2fOWvBHYrW6dmfv/w89lb8WhJTSZEoWgP525jwv/gFeBttlGu1flebdA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': - resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0': + resolution: {integrity: sha512-L8y6piP4jBIIx1Nv7/9hkx25ql6/Cro/kQrs+f9e8bPF0Ar5Dm991v7PnbtubKz6Q4fT872H56QXUWVnz/Cs4Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-http@0.212.0': - resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + '@opentelemetry/exporter-trace-otlp-http@0.213.0': + resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.212.0': - resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@2.5.1': - resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + '@opentelemetry/exporter-zipkin@2.6.0': + resolution: {integrity: sha512-AFP77OQMLfw/Jzh6WT2PtrywstNjdoyT9t9lYrYdk1s4igsvnMZ8DkZKCwxsItC01D+4Lydgrb+Wy0bAvpp8xg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 @@ -3685,62 +3694,62 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-http@0.212.0': - resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + '@opentelemetry/instrumentation-http@0.213.0': + resolution: {integrity: sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-ioredis@0.60.0': - resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + '@opentelemetry/instrumentation-ioredis@0.61.0': + resolution: {integrity: sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-nestjs-core@0.58.0': - resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + '@opentelemetry/instrumentation-nestjs-core@0.59.0': + resolution: {integrity: sha512-tt2cFTENV8XB3D3xjhOz0q4hLc1eqkMZS5UyT9nnHF5FfYH94S2vAGdssvsMv+pFtA6/PmhPUZd4onUN1O7STg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pg@0.64.0': - resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + '@opentelemetry/instrumentation-pg@0.65.0': + resolution: {integrity: sha512-W0zpHEIEuyZ8zvb3njaX9AAbHgPYOsSWVOoWmv1sjVRSF6ZpBqtlxBWbU+6hhq1TFWBeWJOXZ8nZS/PUFpLJYQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.212.0': - resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.212.0': - resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.212.0': - resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + '@opentelemetry/otlp-grpc-exporter-base@0.213.0': + resolution: {integrity: sha512-XgRGuLE9usFNlnw2lgMIM4HTwpcIyjdU/xPoJ8v3LbBLBfjaDkIugjc9HoWa7ZSJ/9Bhzgvm/aD0bGdYUFgnTw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.212.0': - resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagator-b3@2.5.1': - resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + '@opentelemetry/propagator-b3@2.6.0': + resolution: {integrity: sha512-SguK4jMmRvQ0c0dxAMl6K+Eu1+01X0OP7RLiIuHFjOS8hlB23ZYNnhnbAdSQEh5xVXQmH0OAS0TnmVI+6vB2Kg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.5.1': - resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + '@opentelemetry/propagator-jaeger@2.6.0': + resolution: {integrity: sha512-KGWJuvp9X8X36bhHgIhWEnHAzXDInFr+Fvo9IQhhuu6pXLT8mF7HzFyx/X+auZUITvPaZhM39Phj3vK12MbhwA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -3749,38 +3758,38 @@ packages: resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} - '@opentelemetry/resources@2.5.1': - resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.212.0': - resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.5.1': - resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.212.0': - resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + '@opentelemetry/sdk-node@0.213.0': + resolution: {integrity: sha512-8s7SQtY8DIAjraXFrUf0+I90SBAUQbsMWMtUGKmusswRHWXtKJx42aJQMoxEtC82Csqj+IlBH6FoP8XmmUDSrQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.5.1': - resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.5.1': - resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + '@opentelemetry/sdk-trace-node@2.6.0': + resolution: {integrity: sha512-YhswtasmsbIGEFvLGvR9p/y3PVRTfFf+mgY8van4Ygpnv4sA3vooAjvh+qAn9PNWxs4/IwGGqiQS0PPsaRJ0vQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -3916,8 +3925,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.1 - '@photostructure/tz-lookup@11.4.0': - resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==} + '@photostructure/tz-lookup@11.5.0': + resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -4323,8 +4332,8 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.53.3': - resolution: {integrity: sha512-tshOeBUid2v5LAblUpatIdFm5Cyykbw2EiKWOunAAX0A/oJaR7DOdC9wLR5Qqh9zUf3QUISA2m9A3suBdQSYQg==} + '@sveltejs/kit@2.53.4': + resolution: {integrity: sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4432,72 +4441,72 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.15.13': - resolution: {integrity: sha512-ztXusRuC5NV2w+a6pDhX13CGioMLq8CjX5P4XgVJ21ocqz9t19288Do0y8LklplDtwcEhYGTNdMbkmUT7+lDTg==} + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.13': - resolution: {integrity: sha512-cVifxQUKhaE7qcO/y9Mq6PEhoyvN9tSLzCnnFZ4EIabFHBuLtDDO6a+vLveOy98hAs5Qu1+bb5Nv0oa1Pihe3Q==} + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.13': - resolution: {integrity: sha512-t+xxEzZ48enl/wGGy7SRYd7kImWQ/+wvVFD7g5JZo234g6/QnIgZ+YdfIyjHB+ZJI3F7a2IQHS7RNjxF29UkWw==} + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.13': - resolution: {integrity: sha512-VndeGvKmTXFn6AGwjy0Kg8i7HccOCE7Jt/vmZwRxGtOfNZM1RLYRQ7MfDLo6T0h1Bq6eYzps3L5Ma4zBmjOnOg==} + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.13': - resolution: {integrity: sha512-SmZ9m+XqCB35NddHCctvHFLqPZDAs5j8IgD36GoutufDJmeq2VNfgk5rQoqNqKmAK3Y7iFdEmI76QoHIWiCLyw==} + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-x64-gnu@1.15.13': - resolution: {integrity: sha512-5rij+vB9a29aNkHq72EXI2ihDZPszJb4zlApJY4aCC/q6utgqFA6CkrfTfIb+O8hxtG3zP5KERETz8mfFK6A0A==} + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.13': - resolution: {integrity: sha512-OlSlaOK9JplQ5qn07WiBLibkOw7iml2++ojEXhhR3rbWrNEKCD7sd8+6wSavsInyFdw4PhLA+Hy6YyDBIE23Yw==} + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.13': - resolution: {integrity: sha512-zwQii5YVdsfG8Ti9gIKgBKZg8qMkRZxl+OlYWUT5D93Jl4NuNBRausP20tfEkQdAPSRrMCSUZBM6FhW7izAZRg==} + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.13': - resolution: {integrity: sha512-hYXvyVVntqRlYoAIDwNzkS3tL2ijP3rxyWQMNKaxcCxxkCDto/w3meOK/OB6rbQSkNw0qTUcBfU9k+T0ptYdfQ==} + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.13': - resolution: {integrity: sha512-XTzKs7c/vYCcjmcwawnQvlHHNS1naJEAzcBckMI5OJlnrcgW8UtcX9NHFYvNjGtXuKv0/9KvqL4fuahdvlNGKw==} + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.13': - resolution: {integrity: sha512-0l1gl/72PErwUZuavcRpRAQN9uSst+Nk++niC5IX6lmMWpXoScYx3oq/narT64/sKv/eRiPTaAjBFGDEQiWJIw==} + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4976,8 +4985,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/multer@2.0.0': - resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/multer@2.1.0': + resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} '@types/node-forge@1.3.14': resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} @@ -4988,11 +4997,11 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@24.11.0': - resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} - '@types/node@25.3.3': - resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + '@types/node@25.4.0': + resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==} '@types/nodemailer@7.0.11': resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} @@ -5009,8 +5018,8 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} - '@types/pg@8.16.0': - resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/pg@8.18.0': + resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} @@ -5048,8 +5057,8 @@ packages: '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} - '@types/sanitize-html@2.16.0': - resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -5198,11 +5207,11 @@ packages: '@vitest/browser': optional: true - '@vitest/coverage-v8@4.0.14': - resolution: {integrity: sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: - '@vitest/browser': 4.0.14 - vitest: 4.0.14 + '@vitest/browser': 4.0.18 + vitest: 4.0.18 peerDependenciesMeta: '@vitest/browser': optional: true @@ -5210,8 +5219,8 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.0.14': - resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -5224,8 +5233,8 @@ packages: vite: optional: true - '@vitest/mocker@4.0.14': - resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -5238,32 +5247,32 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.0.14': - resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/runner@4.0.14': - resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@vitest/snapshot@4.0.14': - resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.0.14': - resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.0.14': - resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5546,8 +5555,8 @@ packages: ast-metadata-inferer@0.8.1: resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==} - ast-v8-to-istanbul@0.3.8: - resolution: {integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} @@ -5774,8 +5783,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.70.1: - resolution: {integrity: sha512-HjfGHfICkAClrFL0Y07qNbWcmiOCv1l+nusupXUjrvTPuDEyPEJ23MP0lUwUs/QEy1a3pWt/P/sCsSZ1RjRK+w==} + bullmq@5.70.4: + resolution: {integrity: sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -5874,8 +5883,8 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chalk@4.1.2: @@ -5967,8 +5976,8 @@ packages: class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} - class-validator@0.14.4: - resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} clean-css@5.3.3: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} @@ -7207,21 +7216,21 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.51.0: - resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==} + exiftool-vendored.exe@13.52.0: + resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==} os: [win32] - exiftool-vendored.pl@13.51.0: - resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==} + exiftool-vendored.pl@13.52.0: + resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==} os: ['!win32'] hasBin: true - exiftool-vendored@35.10.1: - resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} + exiftool-vendored@35.13.1: + resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==} engines: {node: '>=20.0.0'} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} exponential-backoff@3.1.3: @@ -7586,8 +7595,8 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.3.0: - resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} engines: {node: '>=18'} globalyzer@0.1.0: @@ -7637,8 +7646,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.7.0: - resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==} + happy-dom@20.8.3: + resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -7779,6 +7788,9 @@ packages: webpack: optional: true + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} @@ -7902,8 +7914,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-in-the-middle@2.0.6: - resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} @@ -7965,6 +7978,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ioredis@5.10.0: + resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} + engines: {node: '>=12.22.0'} + ioredis@5.9.3: resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} engines: {node: '>=12.22.0'} @@ -8145,8 +8162,8 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} - is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} is-yarn-global@0.4.1: @@ -8234,6 +8251,9 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8379,10 +8399,6 @@ packages: resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} - kysely@0.28.2: - resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==} - engines: {node: '>=18.0.0'} - langium@3.3.1: resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} engines: {node: '>=16.0.0'} @@ -8650,8 +8666,8 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - magicast@0.5.1: - resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} @@ -9063,10 +9079,6 @@ packages: resolution: {integrity: sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==} deprecated: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.) - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -9114,10 +9126,6 @@ packages: msgpackr@1.11.5: resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - multer@2.0.2: - resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} - engines: {node: '>= 10.16.0'} - multer@2.1.1: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} @@ -9582,27 +9590,27 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.11.0: - resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.12.0: - resolution: {integrity: sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==} + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: pg: '>=8.0' - pg-protocol@1.12.0: - resolution: {integrity: sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==} + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.19.0: - resolution: {integrity: sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==} + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -10181,8 +10189,8 @@ packages: peerDependencies: prettier: ^3.0.0 - prettier-plugin-svelte@3.5.0: - resolution: {integrity: sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==} + prettier-plugin-svelte@3.5.1: + resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==} peerDependencies: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 @@ -10254,10 +10262,6 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} - protobufjs@8.0.0: - resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} - engines: {node: '>=12.0.0'} - protocol-buffers-schema@3.6.0: resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} @@ -10613,8 +10617,8 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup-plugin-visualizer@6.0.5: - resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==} + rollup-plugin-visualizer@6.0.11: + resolution: {integrity: sha512-TBwVHVY7buHjIKVLqr9scTVFwqZqMXINcCphPwIWKPDCOBIa+jCQfafvbjRJDZgXdq/A996Dy6yGJ/+/NtAXDQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -11051,8 +11055,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} strip-bom-string@1.0.0: @@ -11141,17 +11145,17 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.4.3: - resolution: {integrity: sha512-4HtdEv2hOoLCEsSXI+RDELk9okP/4sImWa7X02OjMFFOWeSdFF3NFy3vqpw0z+eH9C88J9vxZfUXz/Uv2A1ANw==} + svelte-check@4.4.4: + resolution: {integrity: sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' - svelte-eslint-parser@1.5.1: - resolution: {integrity: sha512-UbY7DYoDg+x4AKLUcX5xWuEWylgmm8ZD2Z89YT/AK6Wm/ckeMTnOMwr6AVC99znXbRC26xzWEPhSgmB62E07Gg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.30.2} + svelte-eslint-parser@1.6.0: + resolution: {integrity: sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.30.3} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -11214,8 +11218,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.53.5: - resolution: {integrity: sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==} + svelte@5.53.7: + resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11254,8 +11258,8 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} tailwind-variants@3.2.2: resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} @@ -11915,18 +11919,18 @@ packages: jsdom: optional: true - vitest@4.0.14: - resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.14 - '@vitest/browser-preview': 4.0.14 - '@vitest/browser-webdriverio': 4.0.14 - '@vitest/ui': 4.0.14 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -12452,11 +12456,11 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/schematics-cli@19.2.19(@types/node@24.11.0)(chokidar@4.0.3)': + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.12.0)(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@24.11.0) + '@inquirer/prompts': 7.3.2(@types/node@24.12.0) ansi-colors: 4.1.3 symbol-observable: 4.0.0 yargs-parser: 21.1.1 @@ -12513,10 +12517,10 @@ snapshots: '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -12528,15 +12532,15 @@ snapshots: '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -12582,14 +12586,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -12604,7 +12608,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-plugin-utils@7.27.1': {} @@ -12629,7 +12633,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -12643,18 +12647,18 @@ snapshots: dependencies: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 - '@babel/parser@7.28.5': + '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.28.5)': dependencies: @@ -13016,7 +13020,7 @@ snapshots: '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -13196,7 +13200,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 esutils: 2.0.3 '@babel/preset-react@7.28.5(@babel/core@7.28.5)': @@ -13231,22 +13235,22 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 @@ -14788,10 +14792,10 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.1.0 - '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.23 '@grpc/grpc-js@1.14.3': @@ -14941,171 +14945,171 @@ snapshots: commander: 14.0.3 kysely: 0.28.11 kysely-postgres-js: 3.0.0(kysely@0.28.11)(postgres@3.4.8) - pg-connection-string: 2.11.0 + pg-connection-string: 2.12.0 postgres: 3.4.8 - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.5)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.7)': dependencies: front-matter: 4.0.2 marked: 17.0.3 node-emoji: 2.2.0 - svelte: 5.53.5 + svelte: 5.53.7 - '@immich/ui@0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)': + '@immich/ui@0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.5) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.7) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) luxon: 3.7.2 simple-icons: 16.9.0 - svelte: 5.53.5 + svelte: 5.53.7 svelte-highlight: 7.9.0 - tailwind-merge: 3.4.0 - tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1) + tailwind-merge: 3.5.0 + tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) tailwindcss: 4.2.1 transitivePeerDependencies: - '@sveltejs/kit' '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@24.11.0)': + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/confirm@5.1.21(@types/node@24.11.0)': + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/core@10.3.2(@types/node@24.11.0)': + '@inquirer/core@10.3.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/editor@4.2.23(@types/node@24.11.0)': + '@inquirer/editor@4.2.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/external-editor': 1.0.3(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/expand@4.0.23(@types/node@24.11.0)': + '@inquirer/expand@4.0.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/external-editor@1.0.3(@types/node@24.11.0)': + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.11.0)': + '@inquirer/input@4.3.1(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/number@3.0.23(@types/node@24.11.0)': + '@inquirer/number@3.0.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/password@4.0.23(@types/node@24.11.0)': + '@inquirer/password@4.0.23(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/prompts@7.10.1(@types/node@24.11.0)': + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.11.0) - '@inquirer/confirm': 5.1.21(@types/node@24.11.0) - '@inquirer/editor': 4.2.23(@types/node@24.11.0) - '@inquirer/expand': 4.0.23(@types/node@24.11.0) - '@inquirer/input': 4.3.1(@types/node@24.11.0) - '@inquirer/number': 3.0.23(@types/node@24.11.0) - '@inquirer/password': 4.0.23(@types/node@24.11.0) - '@inquirer/rawlist': 4.1.11(@types/node@24.11.0) - '@inquirer/search': 3.2.2(@types/node@24.11.0) - '@inquirer/select': 4.4.2(@types/node@24.11.0) + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/prompts@7.3.2(@types/node@24.11.0)': + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.11.0) - '@inquirer/confirm': 5.1.21(@types/node@24.11.0) - '@inquirer/editor': 4.2.23(@types/node@24.11.0) - '@inquirer/expand': 4.0.23(@types/node@24.11.0) - '@inquirer/input': 4.3.1(@types/node@24.11.0) - '@inquirer/number': 3.0.23(@types/node@24.11.0) - '@inquirer/password': 4.0.23(@types/node@24.11.0) - '@inquirer/rawlist': 4.1.11(@types/node@24.11.0) - '@inquirer/search': 3.2.2(@types/node@24.11.0) - '@inquirer/select': 4.4.2(@types/node@24.11.0) + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/rawlist@4.1.11(@types/node@24.11.0)': + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/search@3.2.2(@types/node@24.11.0)': + '@inquirer/search@3.2.2(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/select@4.4.2(@types/node@24.11.0)': + '@inquirer/select@4.4.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/type@3.0.10(@types/node@24.11.0)': + '@inquirer/type@3.0.10(@types/node@24.12.0)': optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@internationalized/date@3.10.0': dependencies: @@ -15113,11 +15117,13 @@ snapshots: '@ioredis/commands@1.5.0': {} + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -15137,7 +15143,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -15418,49 +15424,49 @@ snapshots: '@namnode/store@0.1.0': {} - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(bullmq@5.70.4)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.70.1 + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.70.4 tslib: 2.8.1 - '@nestjs/cli@11.0.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(@types/node@24.11.0)': + '@nestjs/cli@11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.11.0)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@24.11.0) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.12.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.12.0) '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) ansis: 4.2.0 chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))) glob: 13.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.15.13(@swc/helpers@0.5.17) + '@swc/core': 1.15.18(@swc/helpers@0.5.17) transitivePeerDependencies: - '@types/node' - esbuild - uglify-js - webpack-cli - '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.0 iterare: 1.2.1 @@ -15471,13 +15477,13 @@ snapshots: uid: 2.0.2 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.4 + class-validator: 0.15.1 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -15487,33 +15493,33 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/websockets': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.4 + class-validator: 0.15.1 - '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 - multer: 2.0.2 + multer: 2.1.1 path-to-regexp: 8.3.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2)': + '@nestjs/platform-socket.io@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.3 tslib: 2.8.1 @@ -15522,10 +15528,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': @@ -15539,12 +15545,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) js-yaml: 4.1.1 lodash: 4.17.23 path-to-regexp: 8.3.0 @@ -15552,27 +15558,27 @@ snapshots: swagger-ui-dist: 5.31.0 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.4 + class-validator: 0.15.1 - '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) - '@nestjs/websockets@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(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.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) + '@nestjs/platform-socket.io': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2) '@noble/hashes@1.8.0': {} @@ -15608,131 +15614,131 @@ snapshots: '@oazapfts/runtime@1.2.0': {} - '@opentelemetry/api-logs@0.212.0': + '@opentelemetry/api-logs@0.213.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api@1.9.0': {} - '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) yaml: 2.8.2 - '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-zipkin@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/host-metrics@0.36.2(@opentelemetry/api@1.9.0)': @@ -15740,38 +15746,38 @@ snapshots: '@opentelemetry/api': 1.9.0 systeminformation: 5.23.8 - '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-ioredis@0.61.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common': 0.38.2 '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-nestjs-core@0.59.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.65.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) '@types/pg': 8.15.6 @@ -15779,121 +15785,122 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - import-in-the-middle: 2.0.6 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - protobufjs: 8.0.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 - '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common@0.38.2': {} - '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-node@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/configuration': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@paralleldrive/cuid2@2.3.1': dependencies: @@ -15988,7 +15995,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.1 three: 0.182.0 - '@photostructure/tz-lookup@11.4.0': {} + '@photostructure/tz-lookup@11.5.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -16293,29 +16300,29 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.53.5 - svelte-parse-markup: 0.1.5(svelte@5.53.5) - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + svelte: 5.53.7 + svelte-parse-markup: 0.1.5(svelte@5.53.7) + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.3(rollup@4.55.1) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -16326,30 +16333,30 @@ snapshots: mrmime: 2.0.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + svelte: 5.53.7 + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + svelte: 5.53.7 + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + svelte: 5.53.7 + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -16410,7 +16417,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 entities: 4.5.0 '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': @@ -16446,51 +16453,51 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.15.13': + '@swc/core-darwin-arm64@1.15.18': optional: true - '@swc/core-darwin-x64@1.15.13': + '@swc/core-darwin-x64@1.15.18': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.13': + '@swc/core-linux-arm-gnueabihf@1.15.18': optional: true - '@swc/core-linux-arm64-gnu@1.15.13': + '@swc/core-linux-arm64-gnu@1.15.18': optional: true - '@swc/core-linux-arm64-musl@1.15.13': + '@swc/core-linux-arm64-musl@1.15.18': optional: true - '@swc/core-linux-x64-gnu@1.15.13': + '@swc/core-linux-x64-gnu@1.15.18': optional: true - '@swc/core-linux-x64-musl@1.15.13': + '@swc/core-linux-x64-musl@1.15.18': optional: true - '@swc/core-win32-arm64-msvc@1.15.13': + '@swc/core-win32-arm64-msvc@1.15.18': optional: true - '@swc/core-win32-ia32-msvc@1.15.13': + '@swc/core-win32-ia32-msvc@1.15.18': optional: true - '@swc/core-win32-x64-msvc@1.15.13': + '@swc/core-win32-x64-msvc@1.15.18': optional: true - '@swc/core@1.15.13(@swc/helpers@0.5.17)': + '@swc/core@1.15.18(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.13 - '@swc/core-darwin-x64': 1.15.13 - '@swc/core-linux-arm-gnueabihf': 1.15.13 - '@swc/core-linux-arm64-gnu': 1.15.13 - '@swc/core-linux-arm64-musl': 1.15.13 - '@swc/core-linux-x64-gnu': 1.15.13 - '@swc/core-linux-x64-musl': 1.15.13 - '@swc/core-win32-arm64-msvc': 1.15.13 - '@swc/core-win32-ia32-msvc': 1.15.13 - '@swc/core-win32-x64-msvc': 1.15.13 + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -16568,12 +16575,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/dom@10.4.1': dependencies: @@ -16595,18 +16602,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.53.5)': + '@testing-library/svelte-core@1.0.0(svelte@5.53.7)': dependencies: - svelte: 5.53.5 + svelte: 5.53.7 - '@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.53.5) - svelte: 5.53.5 + '@testing-library/svelte-core': 1.0.0(svelte@5.53.7) + svelte: 5.53.7 optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -16644,7 +16651,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/archiver@7.0.0': dependencies: @@ -16656,16 +16663,16 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/bonjour@3.5.13': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/braces@3.0.5': {} @@ -16687,21 +16694,21 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/compression@1.8.1': dependencies: '@types/express': 5.0.6 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/connect@3.4.38': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/content-disposition@0.5.9': {} @@ -16718,11 +16725,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.6 '@types/keygrip': 1.0.6 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/cors@2.8.19': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/d3-array@3.2.2': {} @@ -16849,13 +16856,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2': 1.15.5 '@types/dom-to-image@2.6.7': {} @@ -16880,14 +16887,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -16913,7 +16920,7 @@ snapshots: '@types/fluent-ffmpeg@2.1.28': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/geojson@7946.0.16': {} @@ -16941,7 +16948,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/inquirer@8.2.12': dependencies: @@ -16965,7 +16972,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/justified-layout@4.1.4': {} @@ -16984,7 +16991,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.9 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/leaflet@1.9.21': dependencies: @@ -17014,17 +17021,17 @@ snapshots: '@types/mock-fs@4.13.4': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ms@2.1.0': {} - '@types/multer@2.0.0': + '@types/multer@2.1.0': dependencies: '@types/express': 5.0.6 '@types/node-forge@1.3.14': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/node@17.0.45': {} @@ -17032,54 +17039,54 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@24.11.0': + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 - '@types/node@25.3.3': + '@types/node@25.4.0': dependencies: undici-types: 7.18.2 optional: true '@types/nodemailer@7.0.11': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/oidc-provider@9.5.0': dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.1 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/parse5@5.0.3': {} '@types/pg-pool@2.0.7': dependencies: - '@types/pg': 8.16.0 + '@types/pg': 8.18.0 '@types/pg@8.15.6': dependencies: - '@types/node': 24.11.0 - pg-protocol: 1.12.0 + '@types/node': 24.12.0 + pg-protocol: 1.13.0 pg-types: 2.2.0 - '@types/pg@8.16.0': + '@types/pg@8.18.0': dependencies: - '@types/node': 24.11.0 - pg-protocol: 1.12.0 + '@types/node': 24.12.0 + pg-protocol: 1.13.0 pg-types: 2.2.0 '@types/picomatch@4.0.2': {} '@types/pngjs@6.0.5': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/prismjs@1.26.5': {} '@types/qrcode@1.5.6': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/qs@6.14.0': {} @@ -17108,28 +17115,28 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/retry@0.12.2': {} - '@types/sanitize-html@2.16.0': + '@types/sanitize-html@2.16.1': dependencies: - htmlparser2: 8.0.2 + htmlparser2: 10.1.0 '@types/sax@1.2.7': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/semver@7.7.1': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/send@1.2.1': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/serve-index@1.9.4': dependencies: @@ -17138,25 +17145,25 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/send': 0.17.6 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/sockjs@0.3.36': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2@0.5.52': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -17167,7 +17174,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.11.0 + '@types/node': 24.12.0 form-data: 4.0.5 '@types/supercluster@7.1.3': @@ -17181,7 +17188,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/trusted-types@2.0.7': {} @@ -17197,7 +17204,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/yargs-parser@21.0.3': {} @@ -17300,11 +17307,11 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.8 + ast-v8-to-istanbul: 0.3.12 debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -17315,43 +17322,37 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.14 - ast-v8-to-istanbul: 0.3.8 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.1 + magicast: 0.5.2 obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - supports-color + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.14 - ast-v8-to-istanbul: 0.3.8 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.1 + magicast: 0.5.2 obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - supports-color + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/expect@3.2.4': dependencies: @@ -17361,44 +17362,44 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.0.14': + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 - chai: 6.2.1 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.14 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.14(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.14 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.0.14': + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -17408,9 +17409,9 @@ snapshots: pathe: 2.0.3 strip-literal: 3.1.0 - '@vitest/runner@4.0.14': + '@vitest/runner@4.0.18': dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.0.18 pathe: 2.0.3 '@vitest/snapshot@3.2.4': @@ -17419,9 +17420,9 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/snapshot@4.0.14': + '@vitest/snapshot@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.18 magic-string: 0.30.21 pathe: 2.0.3 @@ -17429,7 +17430,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.0.14': {} + '@vitest/spy@4.0.18': {} '@vitest/utils@3.2.4': dependencies: @@ -17437,9 +17438,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.0.14': + '@vitest/utils@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 '@webassemblyjs/ast@1.14.1': @@ -17526,10 +17527,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.53.5)': + '@zoom-image/svelte@0.3.9(svelte@5.53.7)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.53.5 + svelte: 5.53.7 abbrev@1.1.1: {} @@ -17751,11 +17752,11 @@ snapshots: dependencies: '@mdn/browser-compat-data': 5.7.6 - ast-v8-to-istanbul@0.3.8: + ast-v8-to-istanbul@0.3.12: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 - js-tokens: 9.0.1 + js-tokens: 10.0.0 astring@1.9.0: {} @@ -17893,15 +17894,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): + bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) - svelte: 5.53.5 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) + svelte: 5.53.7 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -18018,7 +18019,7 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.70.1: + bullmq@5.70.4: dependencies: cron-parser: 4.9.0 ioredis: 5.9.3 @@ -18145,7 +18146,7 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 - chai@6.2.1: {} + chai@6.2.2: {} chalk@4.1.2: dependencies: @@ -18239,7 +18240,7 @@ snapshots: class-transformer@0.5.1: {} - class-validator@0.14.4: + class-validator@0.15.1: dependencies: '@types/validator': 13.15.10 libphonenumber-js: 1.12.38 @@ -19189,7 +19190,7 @@ snapshots: engine.io@6.6.5: dependencies: '@types/cors': 2.8.19 - '@types/node': 24.11.0 + '@types/node': 24.12.0 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -19402,7 +19403,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.5): + eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -19414,9 +19415,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.8) postcss-safe-parser: 7.0.1(postcss@8.5.8) semver: 7.7.4 - svelte-eslint-parser: 1.5.1(svelte@5.53.5) + svelte-eslint-parser: 1.6.0(svelte@5.53.7) optionalDependencies: - svelte: 5.53.5 + svelte: 5.53.7 transitivePeerDependencies: - ts-node @@ -19588,7 +19589,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 require-like: 0.1.2 event-emitter@0.3.5: @@ -19622,23 +19623,23 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.51.0: + exiftool-vendored.exe@13.52.0: optional: true - exiftool-vendored.pl@13.51.0: {} + exiftool-vendored.pl@13.52.0: {} - exiftool-vendored@35.10.1: + exiftool-vendored@35.13.1: dependencies: - '@photostructure/tz-lookup': 11.4.0 + '@photostructure/tz-lookup': 11.5.0 '@types/luxon': 3.7.1 batch-cluster: 17.3.1 - exiftool-vendored.pl: 13.51.0 + exiftool-vendored.pl: 13.52.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.51.0 + exiftool-vendored.exe: 13.52.0 - expect-type@1.2.2: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -19881,7 +19882,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 @@ -19896,7 +19897,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) form-data-encoder@2.1.4: {} @@ -20092,7 +20093,7 @@ snapshots: globals@16.5.0: {} - globals@17.3.0: {} + globals@17.4.0: {} globalyzer@0.1.0: {} @@ -20159,9 +20160,9 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.7.0: + happy-dom@20.8.3: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -20413,6 +20414,13 @@ snapshots: optionalDependencies: webpack: 5.104.1 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@6.1.0: dependencies: domelementtype: 2.3.0 @@ -20552,7 +20560,7 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@2.0.6: + import-in-the-middle@3.0.0: dependencies: acorn: 8.16.0 acorn-import-attributes: 1.9.5(acorn@8.16.0) @@ -20584,9 +20592,9 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@8.2.7(@types/node@24.11.0): + inquirer@8.2.7(@types/node@24.12.0): dependencies: - '@inquirer/external-editor': 1.0.3(@types/node@24.11.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -20626,6 +20634,20 @@ snapshots: dependencies: loose-envify: 1.4.0 + ioredis@5.10.0: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ioredis@5.9.3: dependencies: '@ioredis/commands': 1.5.0 @@ -20757,7 +20779,7 @@ snapshots: dependencies: is-docker: 2.2.1 - is-wsl@3.1.0: + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -20809,7 +20831,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.11.0 + '@types/node': 24.12.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -20817,13 +20839,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -20848,6 +20870,8 @@ snapshots: jose@6.1.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -21040,16 +21064,8 @@ snapshots: optionalDependencies: postgres: 3.4.8 - kysely-postgres-js@3.0.0(kysely@0.28.2)(postgres@3.4.8): - dependencies: - kysely: 0.28.2 - optionalDependencies: - postgres: 3.4.8 - kysely@0.28.11: {} - kysely@0.28.2: {} - langium@3.3.1: dependencies: chevrotain: 11.0.3 @@ -21260,14 +21276,14 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 - magicast@0.5.1: + magicast@0.5.2: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@3.1.0: @@ -21998,10 +22014,6 @@ snapshots: mkdirp@0.3.0: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -22047,16 +22059,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - multer@2.0.2: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - mkdirp: 0.5.6 - object-assign: 4.1.1 - type-is: 1.6.18 - xtend: 4.0.2 - multer@2.1.1: dependencies: append-field: 1.0.0 @@ -22107,39 +22109,39 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@types/inquirer@8.2.12)(@types/node@24.12.0)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) - '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/inquirer': 8.2.12 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) - inquirer: 8.2.7(@types/node@24.11.0) + inquirer: 8.2.7(@types/node@24.12.0) transitivePeerDependencies: - '@types/node' - typescript - nestjs-cls@5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@5.4.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(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.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(kysely@0.28.11)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - kysely: 0.28.2 + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + kysely: 0.28.11 reflect-metadata: 0.2.2 tslib: 2.8.1 - nestjs-otel@7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14): + nestjs-otel@7.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.36.2(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -22374,7 +22376,7 @@ snapshots: log-symbols: 6.0.0 stdin-discarder: 0.2.2 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 p-cancelable@3.0.0: {} @@ -22550,15 +22552,15 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.11.0: {} + pg-connection-string@2.12.0: {} pg-int8@1.0.1: {} - pg-pool@3.12.0(pg@8.19.0): + pg-pool@3.13.0(pg@8.20.0): dependencies: - pg: 8.19.0 + pg: 8.20.0 - pg-protocol@1.12.0: {} + pg-protocol@1.13.0: {} pg-types@2.2.0: dependencies: @@ -22568,11 +22570,11 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.19.0: + pg@8.20.0: dependencies: - pg-connection-string: 2.11.0 - pg-pool: 3.12.0(pg@8.19.0) - pg-protocol: 1.12.0 + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -23153,10 +23155,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.5): + prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7): dependencies: prettier: 3.8.1 - svelte: 5.53.5 + svelte: 5.53.7 prettier@3.8.1: {} @@ -23236,22 +23238,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.11.0 - long: 5.3.2 - - protobufjs@8.0.0: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 24.11.0 + '@types/node': 24.12.0 long: 5.3.2 protocol-buffers-schema@3.6.0: {} @@ -23347,7 +23334,7 @@ snapshots: react-email@4.3.2: dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/traverse': 7.28.5 chokidar: 4.0.3 commander: 13.1.0 @@ -23701,7 +23688,7 @@ snapshots: robust-predicates@3.0.2: {} - rollup-plugin-visualizer@6.0.5(rollup@4.55.1): + rollup-plugin-visualizer@6.0.11(rollup@4.55.1): dependencies: open: 8.4.2 picomatch: 4.0.3 @@ -23776,14 +23763,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): + runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.53.5 + svelte: 5.53.7 optionalDependencies: - '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -24284,13 +24271,13 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string-width@7.2.0: dependencies: emoji-regex: 10.6.0 get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string_decoder@1.1.1: dependencies: @@ -24315,7 +24302,7 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -24407,23 +24394,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.53.5): + svelte-awesome@3.3.5(svelte@5.53.7): dependencies: - svelte: 5.53.5 + svelte: 5.53.7 - svelte-check@4.4.3(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3): + svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.53.5 + svelte: 5.53.7 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.5.1(svelte@5.53.5): + svelte-eslint-parser@1.6.0(svelte@5.53.7): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -24433,7 +24420,7 @@ snapshots: postcss-selector-parser: 7.1.1 semver: 7.7.4 optionalDependencies: - svelte: 5.53.5 + svelte: 5.53.7 svelte-floating-ui@1.5.8: dependencies: @@ -24446,7 +24433,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.53.5): + svelte-i18n@4.0.1(svelte@5.53.7): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24454,10 +24441,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.53.5 + svelte: 5.53.7 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.53.5): + svelte-jsoneditor@3.11.0(svelte@5.53.7): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24484,42 +24471,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.53.5 - svelte-awesome: 3.3.5(svelte@5.53.5) + svelte: 5.53.7 + svelte-awesome: 3.3.5(svelte@5.53.7) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.53.5): + svelte-maplibre@1.2.6(svelte@5.53.7): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.19.0 pmtiles: 3.2.1 - svelte: 5.53.5 + svelte: 5.53.7 - svelte-parse-markup@0.1.5(svelte@5.53.5): + svelte-parse-markup@0.1.5(svelte@5.53.7): dependencies: - svelte: 5.53.5 + svelte: 5.53.7 - svelte-persisted-store@0.12.0(svelte@5.53.5): + svelte-persisted-store@0.12.0(svelte@5.53.7): dependencies: - svelte: 5.53.5 + svelte: 5.53.7 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) style-to-object: 1.0.14 - svelte: 5.53.5 + svelte: 5.53.7 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.53.5: + svelte@5.53.7: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -24573,13 +24560,13 @@ snapshots: tabbable@6.4.0: {} - tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1): + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): dependencies: tailwindcss: 4.2.1 optionalDependencies: - tailwind-merge: 3.4.0 + tailwind-merge: 3.5.0 tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: @@ -24688,16 +24675,16 @@ snapshots: - react-native-b4a optional: true - terser-webpack-plugin@5.3.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))): + terser-webpack-plugin@5.3.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.15.13(@swc/helpers@0.5.17) + '@swc/core': 1.15.18(@swc/helpers@0.5.17) terser-webpack-plugin@5.3.16(webpack@5.104.1): dependencies: @@ -25075,10 +25062,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.9(@swc/core@1.15.13(@swc/helpers@0.5.17))(rollup@4.55.1): + unplugin-swc@1.5.9(@swc/core@1.15.18(@swc/helpers@0.5.17))(rollup@4.55.1): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.55.1) - '@swc/core': 1.15.13(@swc/helpers@0.5.17) + '@swc/core': 1.15.18(@swc/helpers@0.5.17) load-tsconfig: 0.2.5 unplugin: 2.3.11 transitivePeerDependencies: @@ -25212,13 +25199,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25233,17 +25220,17 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -25252,7 +25239,7 @@ snapshots: rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -25261,7 +25248,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -25270,7 +25257,7 @@ snapshots: rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.3 + '@types/node': 25.4.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -25279,19 +25266,19 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25299,7 +25286,7 @@ snapshots: '@vitest/utils': 3.2.4 chai: 5.3.3 debug: 4.4.3 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 @@ -25309,13 +25296,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.11.0 - happy-dom: 20.7.0 + '@types/node': 24.12.0 + happy-dom: 20.8.3 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25331,32 +25318,32 @@ snapshots: - tsx - yaml - vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 24.11.0 - happy-dom: 20.7.0 + '@types/node': 24.12.0 + happy-dom: 20.8.3 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -25371,32 +25358,32 @@ snapshots: - tsx - yaml - vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 24.11.0 - happy-dom: 20.7.0 + '@types/node': 24.12.0 + happy-dom: 20.8.3 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25411,32 +25398,32 @@ snapshots: - tsx - yaml - vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.3.3 - happy-dom: 20.7.0 + '@types/node': 25.4.0 + happy-dom: 20.8.3 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25614,7 +25601,7 @@ snapshots: - esbuild - uglify-js - webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)): + webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -25638,7 +25625,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))) watchpack: 2.5.1 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -25736,7 +25723,7 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrappy@1.0.2: {} @@ -25755,7 +25742,7 @@ snapshots: wsl-utils@0.1.0: dependencies: - is-wsl: 3.1.0 + is-wsl: 3.1.1 xdg-basedir@5.1.0: {} diff --git a/server/package.json b/server/package.json index 943f630687..848229b0e9 100644 --- a/server/package.json +++ b/server/package.json @@ -46,14 +46,14 @@ "@nestjs/websockets": "^11.0.4", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", - "@opentelemetry/exporter-prometheus": "^0.212.0", - "@opentelemetry/instrumentation-http": "^0.212.0", - "@opentelemetry/instrumentation-ioredis": "^0.60.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.58.0", - "@opentelemetry/instrumentation-pg": "^0.64.0", + "@opentelemetry/exporter-prometheus": "^0.213.0", + "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation-ioredis": "^0.61.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.59.0", + "@opentelemetry/instrumentation-pg": "^0.65.0", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1", - "@opentelemetry/sdk-node": "^0.212.0", + "@opentelemetry/sdk-node": "^0.213.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", @@ -66,7 +66,7 @@ "bullmq": "^5.51.0", "chokidar": "^4.0.3", "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", + "class-validator": "^0.15.0", "compression": "^1.8.0", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", @@ -82,7 +82,7 @@ "jose": "^5.10.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", - "kysely": "0.28.2", + "kysely": "0.28.11", "kysely-postgres-js": "^3.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", @@ -136,7 +136,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index 96c84040ca..d8b89d0029 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -1,7 +1,8 @@ import { SharedLinkController } from 'src/controllers/shared-link.controller'; -import { SharedLinkType } from 'src/enum'; +import { Permission, SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import request from 'supertest'; +import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(SharedLinkController.name, () => { @@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => { expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); }); + + describe('DELETE /shared-links/:id/assets', () => { + it('should require shared link update permission', async () => { + await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] }); + + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }), + }), + ); + }); + }); }); diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 1f91409e80..c7ba589a9f 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -180,7 +180,7 @@ export class SharedLinkController { } @Delete(':id/assets') - @Authenticated({ sharedLink: true }) + @Authenticated({ permission: Permission.SharedLinkUpdate }) @Endpoint({ summary: 'Remove assets from a shared link', description: diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index c6821404dc..3345f6e129 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -154,10 +154,11 @@ export class StorageCore { } async moveAssetVideo(asset: StorageAsset) { + const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); return this.moveFile({ entityId: asset.id, pathType: AssetPathType.EncodedVideo, - oldPath: asset.encodedVideoPath, + oldPath: encodedVideoFile?.path || null, newPath: StorageCore.getEncodedVideoPath(asset), }); } @@ -303,21 +304,15 @@ export class StorageCore { case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); } - case AssetFileType.FullSize: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); - } - case AssetFileType.Preview: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); - } - case AssetFileType.Thumbnail: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); - } - case AssetPathType.EncodedVideo: { - return this.assetRepository.update({ id, encodedVideoPath: newPath }); - } + + case AssetFileType.FullSize: + case AssetFileType.EncodedVideo: + case AssetFileType.Thumbnail: + case AssetFileType.Preview: case AssetFileType.Sidecar: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath }); } + case PersonPathType.Face: { return this.personRepository.update({ id, thumbnailPath: newPath }); } diff --git a/server/src/database.ts b/server/src/database.ts index ec614df9e0..3e3192c21a 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,4 +1,4 @@ -import { Selectable } from 'kysely'; +import { Selectable, ShallowDehydrateObject } from 'kysely'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, @@ -16,6 +16,7 @@ import { } from 'src/enum'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { UserMetadataItem } from 'src/types'; @@ -31,7 +32,7 @@ export type AuthUser = { }; export type AlbumUser = { - user: User; + user: ShallowDehydrateObject; role: AlbumUserRole; }; @@ -67,7 +68,7 @@ export type Activity = { updatedAt: Date; albumId: string; userId: string; - user: User; + user: ShallowDehydrateObject; assetId: string | null; comment: string | null; isLiked: boolean; @@ -105,7 +106,7 @@ export type Memory = { data: object; ownerId: string; isSaved: boolean; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; }; export type Asset = { @@ -153,15 +154,14 @@ export type StorageAsset = { id: string; ownerId: string; files: AssetFile[]; - encodedVideoPath: string | null; }; export type Stack = { id: string; primaryAssetId: string; - owner?: User; + owner?: ShallowDehydrateObject; ownerId: string; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; assetCount?: number; }; @@ -177,11 +177,11 @@ export type AuthSharedLink = { export type SharedLink = { id: string; - album?: Album | null; + album?: ShallowDehydrateObject | null; albumId: string | null; allowDownload: boolean; allowUpload: boolean; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; createdAt: Date; description: string | null; expiresAt: Date | null; @@ -194,8 +194,8 @@ export type SharedLink = { }; export type Album = Selectable & { - owner: User; - assets: MapAsset[]; + owner: ShallowDehydrateObject; + assets: ShallowDehydrateObject>[]; }; export type AuthSession = { @@ -205,9 +205,9 @@ export type AuthSession = { export type Partner = { sharedById: string; - sharedBy: User; + sharedBy: ShallowDehydrateObject; sharedWithId: string; - sharedWith: User; + sharedWith: ShallowDehydrateObject; createdAt: Date; createId: string; updatedAt: Date; @@ -270,7 +270,7 @@ export type AssetFace = { imageWidth: number; personId: string | null; sourceType: SourceType; - person?: Person | null; + person?: ShallowDehydrateObject | null; updatedAt: Date; updateId: string; isVisible: boolean; diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index d3536a3482..e82067580b 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -1,18 +1,22 @@ import { mapAlbum } from 'src/dtos/album.dto'; import { AlbumFactory } from 'test/factories/album.factory'; +import { getForAlbum } from 'test/mappers'; describe('mapAlbum', () => { it('should set start and end dates', () => { const startDate = new Date('2023-02-22T05:06:29.716Z'); const endDate = new Date('2025-01-01T01:02:03.456Z'); - const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build(); - const dto = mapAlbum(album, false); - expect(dto.startDate).toEqual(startDate); - expect(dto.endDate).toEqual(endDate); + const album = AlbumFactory.from() + .asset({ localDateTime: endDate }, (builder) => builder.exif()) + .asset({ localDateTime: startDate }, (builder) => builder.exif()) + .build(); + const dto = mapAlbum(getForAlbum(album), false); + expect(dto.startDate).toEqual(startDate.toISOString()); + expect(dto.endDate).toEqual(endDate.toISOString()); }); it('should not set start and end dates for empty assets', () => { - const dto = mapAlbum(AlbumFactory.create(), false); + const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false); expect(dto.startDate).toBeUndefined(); expect(dto.endDate).toBeUndefined(); }); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 62013fbd92..b270125b36 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -1,13 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator'; +import { ShallowDehydrateObject } from 'kysely'; import _ from 'lodash'; import { AlbumUser, AuthSharedLink, User } from 'src/database'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { mapUser, UserResponseDto } from 'src/dtos/user.dto'; import { AlbumUserRole, AssetOrder } from 'src/enum'; +import { MaybeDehydrated } from 'src/types'; +import { asDateString } from 'src/utils/date'; import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class AlbumInfoDto { @@ -151,10 +154,10 @@ export class AlbumResponseDto { albumName!: string; @ApiProperty({ description: 'Album description' }) description!: string; - @ApiProperty({ description: 'Creation date' }) - createdAt!: Date; - @ApiProperty({ description: 'Last update date' }) - updatedAt!: Date; + @ApiProperty({ description: 'Creation date', format: 'date-time' }) + createdAt!: string; + @ApiProperty({ description: 'Last update date', format: 'date-time' }) + updatedAt!: string; @ApiProperty({ description: 'Thumbnail asset ID' }) albumThumbnailAssetId!: string | null; @ApiProperty({ description: 'Is shared album' }) @@ -172,12 +175,12 @@ export class AlbumResponseDto { owner!: UserResponseDto; @ApiProperty({ type: 'integer', description: 'Number of assets' }) assetCount!: number; - @ApiPropertyOptional({ description: 'Last modified asset timestamp' }) - lastModifiedAssetTimestamp?: Date; - @ApiPropertyOptional({ description: 'Start date (earliest asset)' }) - startDate?: Date; - @ApiPropertyOptional({ description: 'End date (latest asset)' }) - endDate?: Date; + @ApiPropertyOptional({ description: 'Last modified asset timestamp', format: 'date-time' }) + lastModifiedAssetTimestamp?: string; + @ApiPropertyOptional({ description: 'Start date (earliest asset)', format: 'date-time' }) + startDate?: string; + @ApiPropertyOptional({ description: 'End date (latest asset)', format: 'date-time' }) + endDate?: string; @ApiProperty({ description: 'Activity feed enabled' }) isActivityEnabled!: boolean; @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true }) @@ -191,8 +194,8 @@ export class AlbumResponseDto { export type MapAlbumDto = { albumUsers?: AlbumUser[]; - assets?: MapAsset[]; - sharedLinks?: AuthSharedLink[]; + assets?: ShallowDehydrateObject[]; + sharedLinks?: ShallowDehydrateObject[]; albumName: string; description: string; albumThumbnailAssetId: string | null; @@ -200,12 +203,16 @@ export type MapAlbumDto = { updatedAt: Date; id: string; ownerId: string; - owner: User; + owner: ShallowDehydrateObject; isActivityEnabled: boolean; order: AssetOrder; }; -export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { +export const mapAlbum = ( + entity: MaybeDehydrated, + withAssets: boolean, + auth?: AuthDto, +): AlbumResponseDto => { const albumUsers: AlbumUserResponseDto[] = []; if (entity.albumUsers) { @@ -236,16 +243,16 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt albumName: entity.albumName, description: entity.description, albumThumbnailAssetId: entity.albumThumbnailAssetId, - createdAt: entity.createdAt, - updatedAt: entity.updatedAt, + createdAt: asDateString(entity.createdAt), + updatedAt: asDateString(entity.updatedAt), id: entity.id, ownerId: entity.ownerId, owner: mapUser(entity.owner), albumUsers: albumUsersSorted, shared: hasSharedUser || hasSharedLink, hasSharedLink, - startDate, - endDate, + startDate: asDateString(startDate), + endDate: asDateString(endDate), assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })), assetCount: entity.assets?.length || 0, isActivityEnabled: entity.isActivityEnabled, @@ -253,5 +260,5 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt }; }; -export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true); -export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false); +export const mapAlbumWithAssets = (entity: MaybeDehydrated) => mapAlbum(entity, true); +export const mapAlbumWithoutAssets = (entity: MaybeDehydrated) => mapAlbum(entity, false); diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts index ff3b3f6acd..8e85b983c3 100644 --- a/server/src/dtos/asset-response.dto.spec.ts +++ b/server/src/dtos/asset-response.dto.spec.ts @@ -3,6 +3,7 @@ import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { PersonFactory } from 'test/factories/person.factory'; +import { getForAsset } from 'test/mappers'; describe('mapAsset', () => { describe('peopleWithFaces', () => { @@ -41,7 +42,7 @@ describe('mapAsset', () => { }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); @@ -80,7 +81,7 @@ describe('mapAsset', () => { .edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.unassignedFaces).toBeDefined(); expect(result.unassignedFaces).toHaveLength(1); @@ -130,7 +131,7 @@ describe('mapAsset', () => { .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(2); @@ -179,7 +180,7 @@ describe('mapAsset', () => { .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index a76df4abaa..8b38b2e124 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Selectable } from 'kysely'; +import { Selectable, ShallowDehydrateObject } from 'kysely'; import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database'; import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -14,9 +14,10 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { ImageDimensions } from 'src/types'; +import { ImageDimensions, MaybeDehydrated } from 'src/types'; import { getDimensions } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; +import { asDateString } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; import { ValidateEnum, ValidateUUID } from 'src/validation'; @@ -39,7 +40,7 @@ export class SanitizedAssetResponseDto { 'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.', example: '2024-01-15T14:30:00.000Z', }) - localDateTime!: Date; + localDateTime!: string; @ApiProperty({ description: 'Video duration (for videos)' }) duration!: string; @ApiPropertyOptional({ description: 'Live photo video ID' }) @@ -59,7 +60,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { description: 'The UTC timestamp when the asset was originally uploaded to Immich.', example: '2024-01-15T20:30:00.000Z', }) - createdAt!: Date; + createdAt!: string; @ApiProperty({ description: 'Device asset ID' }) deviceAssetId!: string; @ApiProperty({ description: 'Device ID' }) @@ -86,7 +87,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { 'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.', example: '2024-01-15T19:30:00.000Z', }) - fileCreatedAt!: Date; + fileCreatedAt!: string; @ApiProperty({ type: 'string', format: 'date-time', @@ -94,7 +95,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { 'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.', example: '2024-01-16T10:15:00.000Z', }) - fileModifiedAt!: Date; + fileModifiedAt!: string; @ApiProperty({ type: 'string', format: 'date-time', @@ -102,7 +103,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { 'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.', example: '2024-01-16T12:45:30.000Z', }) - updatedAt!: Date; + updatedAt!: string; @ApiProperty({ description: 'Is favorite' }) isFavorite!: boolean; @ApiProperty({ description: 'Is archived' }) @@ -151,13 +152,12 @@ export type MapAsset = { deviceId: string; duplicateId: string | null; duration: string | null; - edits?: AssetEditActionItem[]; - encodedVideoPath: string | null; - exifInfo?: Selectable | null; - faces?: AssetFace[]; + edits?: ShallowDehydrateObject[]; + exifInfo?: ShallowDehydrateObject> | null; + faces?: ShallowDehydrateObject[]; fileCreatedAt: Date; fileModifiedAt: Date; - files?: AssetFile[]; + files?: ShallowDehydrateObject[]; isExternal: boolean; isFavorite: boolean; isOffline: boolean; @@ -167,11 +167,11 @@ export type MapAsset = { localDateTime: Date; originalFileName: string; originalPath: string; - owner?: User | null; + owner?: ShallowDehydrateObject | null; ownerId: string; - stack?: Stack | null; + stack?: (ShallowDehydrateObject & { assets: Stack['assets'] }) | null; stackId: string | null; - tags?: Tag[]; + tags?: ShallowDehydrateObject[]; thumbhash: Buffer | null; type: AssetType; width: number | null; @@ -197,7 +197,7 @@ export type AssetMapOptions = { }; const peopleWithFaces = ( - faces?: AssetFace[], + faces?: MaybeDehydrated[], edits?: AssetEditActionItem[], assetDimensions?: ImageDimensions, ): PersonWithFacesResponseDto[] => { @@ -213,7 +213,10 @@ const peopleWithFaces = ( } if (!peopleFaces.has(face.person.id)) { - peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] }); + peopleFaces.set(face.person.id, { + ...mapPerson(face.person), + faces: [], + }); } const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions); peopleFaces.get(face.person.id)!.faces.push(mappedFace); @@ -234,7 +237,7 @@ const mapStack = (entity: { stack?: Stack | null }) => { }; }; -export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto { +export function mapAsset(entity: MaybeDehydrated, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; if (stripMetadata) { @@ -243,7 +246,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset type: entity.type, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - localDateTime: entity.localDateTime, + localDateTime: asDateString(entity.localDateTime), duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -257,7 +260,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset return { id: entity.id, - createdAt: entity.createdAt, + createdAt: asDateString(entity.createdAt), deviceAssetId: entity.deviceAssetId, ownerId: entity.ownerId, owner: entity.owner ? mapUser(entity.owner) : undefined, @@ -268,10 +271,10 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - fileCreatedAt: entity.fileCreatedAt, - fileModifiedAt: entity.fileModifiedAt, - localDateTime: entity.localDateTime, - updatedAt: entity.updatedAt, + fileCreatedAt: asDateString(entity.fileCreatedAt), + fileModifiedAt: asDateString(entity.fileModifiedAt), + localDateTime: asDateString(entity.localDateTime), + updatedAt: asDateString(entity.updatedAt), isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite, isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, @@ -283,7 +286,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset people: peopleWithFaces(entity.faces, entity.edits, assetDimensions), unassignedFaces: entity.faces ?.filter((face) => !face.person) - .map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)), + .map((face) => mapFacesWithoutPerson(face, entity.edits, assetDimensions)), checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 0052b95b6e..165ecde4db 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Exif } from 'src/database'; +import { MaybeDehydrated } from 'src/types'; +import { asDateString } from 'src/utils/date'; export class ExifResponseDto { @ApiPropertyOptional({ description: 'Camera make' }) @@ -16,9 +18,9 @@ export class ExifResponseDto { @ApiPropertyOptional({ description: 'Image orientation' }) orientation?: string | null = null; @ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' }) - dateTimeOriginal?: Date | null = null; + dateTimeOriginal?: string | null = null; @ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' }) - modifyDate?: Date | null = null; + modifyDate?: string | null = null; @ApiPropertyOptional({ description: 'Time zone' }) timeZone?: string | null = null; @ApiPropertyOptional({ description: 'Lens model' }) @@ -49,7 +51,7 @@ export class ExifResponseDto { rating?: number | null = null; } -export function mapExif(entity: Exif): ExifResponseDto { +export function mapExif(entity: MaybeDehydrated): ExifResponseDto { return { make: entity.make, model: entity.model, @@ -57,8 +59,8 @@ export function mapExif(entity: Exif): ExifResponseDto { exifImageHeight: entity.exifImageHeight, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, - dateTimeOriginal: entity.dateTimeOriginal, - modifyDate: entity.modifyDate, + dateTimeOriginal: asDateString(entity.dateTimeOriginal), + modifyDate: asDateString(entity.modifyDate), timeZone: entity.timeZone, lensModel: entity.lensModel, fNumber: entity.fNumber, @@ -80,7 +82,7 @@ export function mapSanitizedExif(entity: Exif): ExifResponseDto { return { fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, - dateTimeOriginal: entity.dateTimeOriginal, + dateTimeOriginal: asDateString(entity.dateTimeOriginal), timeZone: entity.timeZone, projectionType: entity.projectionType, exifImageWidth: entity.exifImageWidth, diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 983062afcf..477166d3d5 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -9,8 +9,8 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SourceType } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { ImageDimensions } from 'src/types'; -import { asDateString } from 'src/utils/date'; +import { ImageDimensions, MaybeDehydrated } from 'src/types'; +import { asBirthDateString, asDateString } from 'src/utils/date'; import { transformFaceBoundingBox } from 'src/utils/transform'; import { IsDateStringFormat, @@ -33,7 +33,7 @@ export class PersonCreateDto { @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' }) @IsDateStringFormat('yyyy-MM-dd') @Optional({ nullable: true, emptyToNull: true }) - birthDate?: Date | null; + birthDate?: string | null; @ValidateBoolean({ optional: true, description: 'Person visibility (hidden)' }) isHidden?: boolean; @@ -105,8 +105,12 @@ export class PersonResponseDto { thumbnailPath!: string; @ApiProperty({ description: 'Is hidden' }) isHidden!: boolean; - @Property({ description: 'Last update date', history: new HistoryBuilder().added('v1.107.0').stable('v2') }) - updatedAt?: Date; + @Property({ + description: 'Last update date', + format: 'date-time', + history: new HistoryBuilder().added('v1.107.0').stable('v2'), + }) + updatedAt?: string; @Property({ description: 'Is favorite', history: new HistoryBuilder().added('v1.126.0').stable('v2') }) isFavorite?: boolean; @Property({ description: 'Person color (hex)', history: new HistoryBuilder().added('v1.126.0').stable('v2') }) @@ -222,21 +226,21 @@ export class PeopleResponseDto { hasNextPage?: boolean; } -export function mapPerson(person: Person): PersonResponseDto { +export function mapPerson(person: MaybeDehydrated): PersonResponseDto { return { id: person.id, name: person.name, - birthDate: asDateString(person.birthDate), + birthDate: asBirthDateString(person.birthDate), thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, isFavorite: person.isFavorite, color: person.color ?? undefined, - updatedAt: person.updatedAt, + updatedAt: asDateString(person.updatedAt), }; } export function mapFacesWithoutPerson( - face: Selectable, + face: MaybeDehydrated>, edits?: AssetEditActionItem[], assetDimensions?: ImageDimensions, ): AssetFaceWithoutPersonResponseDto { diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index bb33659bfe..ea85ea71f3 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; import { Tag } from 'src/database'; +import { MaybeDehydrated } from 'src/types'; +import { asDateString } from 'src/utils/date'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; export class TagCreateDto { @@ -54,22 +56,22 @@ export class TagResponseDto { name!: string; @ApiProperty({ description: 'Tag value (full path)' }) value!: string; - @ApiProperty({ description: 'Creation date' }) - createdAt!: Date; - @ApiProperty({ description: 'Last update date' }) - updatedAt!: Date; + @ApiProperty({ description: 'Creation date', format: 'date-time' }) + createdAt!: string; + @ApiProperty({ description: 'Last update date', format: 'date-time' }) + updatedAt!: string; @ApiPropertyOptional({ description: 'Tag color (hex)' }) color?: string; } -export function mapTag(entity: Tag): TagResponseDto { +export function mapTag(entity: MaybeDehydrated): TagResponseDto { return { id: entity.id, parentId: entity.parentId ?? undefined, name: entity.value.split('/').at(-1) as string, value: entity.value, - createdAt: entity.createdAt, - updatedAt: entity.updatedAt, + createdAt: asDateString(entity.createdAt), + updatedAt: asDateString(entity.updatedAt), color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 2d4fc3934f..ebd0018bba 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -3,7 +3,8 @@ import { Transform } from 'class-transformer'; import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; -import { UserMetadataItem } from 'src/types'; +import { MaybeDehydrated, UserMetadataItem } from 'src/types'; +import { asDateString } from 'src/utils/date'; import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation'; export class UserUpdateMeDto { @@ -47,8 +48,8 @@ export class UserResponseDto { profileImagePath!: string; @ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'Avatar color' }) avatarColor!: UserAvatarColor; - @ApiProperty({ description: 'Profile change date' }) - profileChangedAt!: Date; + @ApiProperty({ description: 'Profile change date', format: 'date-time' }) + profileChangedAt!: string; } export class UserLicense { @@ -68,14 +69,14 @@ const emailToAvatarColor = (email: string): UserAvatarColor => { return values[randomIndex]; }; -export const mapUser = (entity: User | UserAdmin): UserResponseDto => { +export const mapUser = (entity: MaybeDehydrated): UserResponseDto => { return { id: entity.id, email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email), - profileChangedAt: entity.profileChangedAt, + profileChangedAt: asDateString(entity.profileChangedAt), }; }; diff --git a/server/src/enum.ts b/server/src/enum.ts index 887c8fa93c..60f45efd6e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,7 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + EncodedVideo = 'encoded_video', } export enum AlbumUserRole { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index a9c407782b..cebb9fe95e 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -175,7 +175,6 @@ where select "asset"."id", "asset"."ownerId", - "asset"."encodedVideoPath", ( select coalesce(json_agg(agg), '[]') @@ -463,7 +462,6 @@ select "asset"."libraryId", "asset"."ownerId", "asset"."livePhotoVideoId", - "asset"."encodedVideoPath", "asset"."originalPath", "asset"."isOffline", to_json("asset_exif") as "exifInfo", @@ -521,12 +519,17 @@ select from "asset" where - "asset"."type" = $1 - and ( - "asset"."encodedVideoPath" is null - or "asset"."encodedVideoPath" = $2 + "asset"."type" = 'VIDEO' + and not exists ( + select + "asset_file"."id" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = 'encoded_video' ) - and "asset"."visibility" != $3 + and "asset"."visibility" != 'hidden' and "asset"."deletedAt" is null -- AssetJobRepository.getForVideoConversion @@ -534,12 +537,27 @@ select "asset"."id", "asset"."ownerId", "asset"."originalPath", - "asset"."encodedVideoPath" + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type", + "asset_file"."isEdited" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + ) as agg + ) as "files" from "asset" where "asset"."id" = $1 - and "asset"."type" = $2 + and "asset"."type" = 'VIDEO' -- AssetJobRepository.streamForMetadataExtraction select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 632fb823c6..a2525c3b17 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -438,6 +438,7 @@ with and "stack"."primaryAssetId" != "asset"."id" ) order by + (asset."localDateTime" AT TIME ZONE 'UTC')::date desc, "asset"."fileCreatedAt" desc ), "agg" as ( @@ -628,13 +629,21 @@ order by -- AssetRepository.getForVideo select - "asset"."encodedVideoPath", - "asset"."originalPath" + "asset"."originalPath", + ( + select + "asset_file"."path" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as "encodedVideoPath" from "asset" where - "asset"."id" = $1 - and "asset"."type" = $2 + "asset"."id" = $2 + and "asset"."type" = $3 -- AssetRepository.getForOcr select diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index cdd20ef85d..2630e384fc 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -244,3 +244,37 @@ where or "album"."id" is not null ) and "shared_link"."slug" = $2 + +-- SharedLinkRepository.getSharedLinks +select + "shared_link".*, + coalesce( + json_agg("assets") filter ( + where + "assets"."id" is not null + ), + '[]' + ) as "assets" +from + "shared_link" + left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id" + left join lateral ( + select + "asset".* + from + "asset" + inner join lateral ( + select + * + from + "asset_exif" + where + "asset_exif"."assetId" = "asset"."id" + ) as "exifInfo" on true + where + "asset"."id" = "shared_link_asset"."assetId" + ) as "assets" on true +where + "shared_link"."id" = $1 +group by + "shared_link"."id" diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index cf132a023d..9a76b379ed 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -1,12 +1,22 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely'; +import { + ExpressionBuilder, + Insertable, + Kysely, + NotNull, + Selectable, + ShallowDehydrateObject, + sql, + Updateable, +} from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { columns, Exif } from 'src/database'; +import { columns } from 'src/database'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { withDefaultVisibility } from 'src/utils/database'; export interface AlbumAssetCount { @@ -56,7 +66,9 @@ const withAssets = (eb: ExpressionBuilder) => { .selectFrom('asset') .selectAll('asset') .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.table('asset_exif').$castTo().as('exifInfo')) + .select((eb) => + eb.table('asset_exif').$castTo>>().as('exifInfo'), + ) .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') .whereRef('album_asset.albumId', '=', 'album.id') .where('asset.deletedAt', 'is', null) diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index df9b50791f..3765cad7ed 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -9,7 +9,6 @@ import { DB } from 'src/schema'; import { anyUuid, asUuid, - toJson, withDefaultVisibility, withEdits, withExif, @@ -105,7 +104,7 @@ export class AssetJobRepository { getForMigrationJob(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId']) .select(withFiles) .where('asset.id', '=', id) .executeTakeFirst(); @@ -269,7 +268,6 @@ export class AssetJobRepository { 'asset.libraryId', 'asset.ownerId', 'asset.livePhotoVideoId', - 'asset.encodedVideoPath', 'asset.originalPath', 'asset.isOffline', ]) @@ -296,7 +294,12 @@ export class AssetJobRepository { .as('stack_result'), (join) => join.onTrue(), ) - .select((eb) => toJson(eb, 'stack_result').as('stack')) + .select((eb) => + eb.fn + .toJson(eb.table('stack_result')) + .$castTo<{ id: string; primaryAssetId: string; assets: { id: string }[] } | null>() + .as('stack'), + ) .where('asset.id', '=', id) .executeTakeFirst(); } @@ -306,11 +309,21 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .select(['asset.id']) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .$if(!force, (qb) => qb - .where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')])) - .where('asset.visibility', '!=', AssetVisibility.Hidden), + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.id') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)), + ), + ), + ) + .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), ) .where('asset.deletedAt', 'is', null) .stream(); @@ -320,9 +333,10 @@ export class AssetJobRepository { getForVideoConversion(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId', 'asset.originalPath']) + .select(withFiles) .where('asset.id', '=', id) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .executeTakeFirst(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e971a995e6..2e1d02ef28 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -6,6 +6,7 @@ import { NotNull, Selectable, SelectQueryBuilder, + ShallowDehydrateObject, sql, Updateable, UpdateResult, @@ -35,6 +36,7 @@ import { withExif, withFaces, withFacesAndPeople, + withFilePath, withFiles, withLibrary, withOwner, @@ -554,7 +556,11 @@ export class AssetRepository { eb .selectFrom('asset as stacked') .selectAll('stack') - .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) + .select((eb) => + eb + .fn>>('array_agg', [eb.table('stacked')]) + .as('assets'), + ) .whereRef('stacked.stackId', '=', 'stack.id') .whereRef('stacked.id', '!=', 'stack.primaryAssetId') .where('stacked.deletedAt', 'is', null) @@ -563,7 +569,7 @@ export class AssetRepository { .as('stacked_assets'), (join) => join.on('stack.id', 'is not', null), ) - .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo().as('stack')), + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), ), ) .$if(!!files, (qb) => qb.select(withFiles)) @@ -744,6 +750,7 @@ export class AssetRepository { params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }], }) getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) { + const order = options.order ?? 'desc'; const query = this.db .with('cte', (qb) => qb @@ -841,7 +848,8 @@ export class AssetRepository { ) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .orderBy('asset.fileCreatedAt', options.order ?? 'desc'), + .orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order) + .orderBy('asset.fileCreatedAt', order), ) .with('agg', (qb) => qb @@ -1012,8 +1020,21 @@ export class AssetRepository { .execute(); } - async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise { - await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute(); + async deleteFile({ + assetId, + type, + edited, + }: { + assetId: string; + type: AssetFileType; + edited?: boolean; + }): Promise { + await this.db + .deleteFrom('asset_file') + .where('assetId', '=', asUuid(assetId)) + .where('type', '=', type) + .$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!)) + .execute(); } async deleteFiles(files: Pick, 'id'>[]): Promise { @@ -1132,7 +1153,8 @@ export class AssetRepository { async getForVideo(id: string) { return this.db .selectFrom('asset') - .select(['asset.encodedVideoPath', 'asset.originalPath']) + .select(['asset.originalPath']) + .select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath')) .where('asset.id', '=', id) .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 4ffb37c79c..7ae1119bbc 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -431,7 +431,6 @@ export class DatabaseRepository { .updateTable('asset') .set((eb) => ({ originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]), - encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]), })) .execute(); diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index 95ccbea63d..7a5931e029 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { Kysely, NotNull, sql } from 'kysely'; +import { Kysely, NotNull, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; -import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetType, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database'; interface DuplicateSearch { @@ -39,15 +39,15 @@ export class DuplicateRepository { qb .selectFrom('asset_exif') .selectAll('asset') - .select((eb) => eb.table('asset_exif').as('exifInfo')) + .select((eb) => + eb.table('asset_exif').$castTo>>().as('exifInfo'), + ) .whereRef('asset_exif.assetId', '=', 'asset.id') .as('asset2'), (join) => join.onTrue(), ) .select('asset.duplicateId') - .select((eb) => - eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets'), - ) + .select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets')) .where('asset.ownerId', '=', asUuid(userId)) .where('asset.duplicateId', 'is not', null) .$narrowType<{ duplicateId: NotNull }>() diff --git a/server/src/repositories/email.repository.ts b/server/src/repositories/email.repository.ts index 1bc4f0981a..a0cc23661a 100644 --- a/server/src/repositories/email.repository.ts +++ b/server/src/repositories/email.repository.ts @@ -162,6 +162,7 @@ export class EmailRepository { host: options.host, port: options.port, tls: { rejectUnauthorized: !options.ignoreCert }, + secure: options.secure, auth: options.username || options.password ? { diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3c36bf62db..fc00d44b3f 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -72,6 +72,8 @@ export interface ImmichTags extends Omit { AndroidMake?: string; AndroidModel?: string; + DeviceManufacturer?: string; + DeviceModelName?: string; } @Injectable() diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index a42955ba10..5af5163f8f 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -70,7 +70,16 @@ export class OAuthRepository { try { const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier }); - const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + + let profile: OAuthProfile; + const tokenClaims = tokens.claims(); + if (tokenClaims && 'email' in tokenClaims) { + this.logger.debug('Using ID token claims instead of userinfo endpoint'); + profile = tokenClaims as OAuthProfile; + } else { + profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + } + if (!profile.sub) { throw new Error('Unexpected profile response, no `sub`'); } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 615b35c417..13ac254654 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Kysely, OrderByDirection, Selectable, sql } from 'kysely'; +import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { randomUUID } from 'node:crypto'; import { DummyValue, GenerateSql } from 'src/decorators'; @@ -433,7 +433,7 @@ export class SearchRepository { .select((eb) => eb .fn('to_jsonb', [eb.table('asset_exif')]) - .$castTo>() + .$castTo>>() .as('exifInfo'), ) .orderBy('asset_exif.city') diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 2a8acd6377..bc81e75c81 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -1,13 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Album, columns } from 'src/database'; -import { DummyValue, GenerateSql } from 'src/decorators'; -import { MapAsset } from 'src/dtos/asset-response.dto'; +import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkType } from 'src/enum'; import { DB } from 'src/schema'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; export type SharedLinkSearchOptions = { @@ -106,11 +107,15 @@ export class SharedLinkRepository { .select((eb) => eb.fn .coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`) - .$castTo() + .$castTo< + (ShallowDehydrateObject> & { + exifInfo: ShallowDehydrateObject>; + })[] + >() .as('assets'), ) .groupBy(['shared_link.id', sql`"album".*`]) - .select((eb) => eb.fn.toJson('album').$castTo().as('album')) + .select((eb) => eb.fn.toJson(eb.table('album')).$castTo | null>().as('album')) .where('shared_link.id', '=', id) .where('shared_link.userId', '=', userId) .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) @@ -134,9 +139,7 @@ export class SharedLinkRepository { .selectAll('asset') .orderBy('asset.fileCreatedAt', 'asc') .limit(1), - ) - .$castTo() - .as('assets'), + ).as('assets'), ) .leftJoinLateral( (eb) => @@ -175,7 +178,7 @@ export class SharedLinkRepository { .as('album'), (join) => join.onTrue(), ) - .select((eb) => eb.fn.toJson('album').$castTo().as('album')) + .select((eb) => eb.fn.toJson('album').$castTo | null>().as('album')) .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) .$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!)) .$if(!!id, (eb) => eb.where('shared_link.id', '=', id!)) @@ -246,6 +249,21 @@ export class SharedLinkRepository { await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute(); } + @ChunkedArray({ paramIndex: 1 }) + async addAssets(id: string, assetIds: string[]) { + if (assetIds.length === 0) { + return []; + } + + return await this.db + .insertInto('shared_link_asset') + .values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id }))) + .onConflict((oc) => oc.doNothing()) + .returning(['shared_link_asset.assetId']) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) private getSharedLinks(id: string) { return this.db .selectFrom('shared_link') @@ -269,7 +287,11 @@ export class SharedLinkRepository { .select((eb) => eb.fn .coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`) - .$castTo() + .$castTo< + (ShallowDehydrateObject> & { + exifInfo: ShallowDehydrateObject>; + })[] + >() .as('assets'), ) .groupBy('shared_link.id') diff --git a/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts new file mode 100644 index 0000000000..4a62a7e842 --- /dev/null +++ b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts @@ -0,0 +1,25 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + INSERT INTO "asset_file" ("assetId", "type", "path") + SELECT "id", 'encoded_video', "encodedVideoPath" + FROM "asset" + WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != ''; + `.execute(db); + + await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db); + + await sql` + UPDATE "asset" + SET "encodedVideoPath" = af."path" + FROM "asset_file" af + WHERE "asset"."id" = af."assetId" + AND af."type" = 'encoded_video' + AND af."isEdited" = false; + `.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 12e9c36125..8bdaa59bc6 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -92,9 +92,6 @@ export class AssetTable { @Column({ type: 'character varying', nullable: true }) duration!: string | null; - @Column({ type: 'character varying', nullable: true, default: '' }) - encodedVideoPath!: string | null; - @Column({ type: 'bytea', index: true }) checksum!: Buffer; // sha1 checksum diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index aea547e6db..03cd0132c1 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; -import { factory, newUuid, newUuids } from 'test/small.factory'; +import { ActivityFactory } from 'test/factories/activity.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { getForActivity } from 'test/mappers'; +import { newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { @@ -23,7 +26,7 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); }); @@ -35,7 +38,7 @@ describe(ActivityService.name, () => { mocks.activity.search.mockResolvedValue([]); await expect( - sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }), + sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), ).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); @@ -47,7 +50,9 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual( + [], + ); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false }); }); @@ -60,7 +65,10 @@ describe(ActivityService.name, () => { mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); - await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 }); + await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({ + comments: 1, + likes: 3, + }); }); }); @@ -69,18 +77,18 @@ describe(ActivityService.name, () => { const [albumId, assetId] = newUuids(); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a comment', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ albumId, assetId, userId }); + const activity = ActivityFactory.create({ albumId, assetId, userId }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.create.mockResolvedValue(activity); + mocks.activity.create.mockResolvedValue(getForActivity(activity)); - await sut.create(factory.auth({ user: { id: userId } }), { + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.COMMENT, @@ -98,38 +106,38 @@ describe(ActivityService.name, () => { it('should fail because activity is disabled for the album', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId }); + const activity = ActivityFactory.create({ albumId, assetId }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.create.mockResolvedValue(activity); + mocks.activity.create.mockResolvedValue(getForActivity(activity)); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a like', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ userId, albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.create.mockResolvedValue(activity); + mocks.activity.create.mockResolvedValue(getForActivity(activity)); mocks.activity.search.mockResolvedValue([]); - await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); }); it('should skip if like exists', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ albumId, assetId, isLiked: true }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.search.mockResolvedValue([activity]); + mocks.activity.search.mockResolvedValue([getForActivity(activity)]); - await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).not.toHaveBeenCalled(); }); @@ -137,29 +145,29 @@ describe(ActivityService.name, () => { describe('delete', () => { it('should require access', async () => { - await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException); expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); it('should let the album owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index d21185bd35..47646d0c6d 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -1,5 +1,4 @@ import { BadRequestException } from '@nestjs/common'; -import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum'; import { AlbumService } from 'src/services/album.service'; @@ -9,6 +8,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAlbum } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -45,7 +45,7 @@ describe(AlbumService.name, () => { it('gets list of albums for auth user', async () => { const album = AlbumFactory.from().albumUser().build(); const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build(); - mocks.album.getOwned.mockResolvedValue([album, sharedWithUserAlbum]); + mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -70,8 +70,13 @@ describe(AlbumService.name, () => { }); it('gets list of albums that have a specific asset', async () => { - const album = AlbumFactory.from().owner({ isAdmin: true }).albumUser().asset().asset().build(); - mocks.album.getByAssetId.mockResolvedValue([album]); + const album = AlbumFactory.from() + .owner({ isAdmin: true }) + .albumUser() + .asset({}, (builder) => builder.exif()) + .asset({}, (builder) => builder.exif()) + .build(); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -90,7 +95,7 @@ describe(AlbumService.name, () => { it('gets list of albums that are shared', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getShared.mockResolvedValue([album]); + mocks.album.getShared.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -109,7 +114,7 @@ describe(AlbumService.name, () => { it('gets list of albums that are NOT shared', async () => { const album = AlbumFactory.create(); - mocks.album.getNotShared.mockResolvedValue([album]); + mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -129,7 +134,7 @@ describe(AlbumService.name, () => { it('counts assets correctly', async () => { const album = AlbumFactory.create(); - mocks.album.getOwned.mockResolvedValue([album]); + mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -155,7 +160,7 @@ describe(AlbumService.name, () => { .albumUser(albumUser) .build(); - mocks.album.create.mockResolvedValue(album); + mocks.album.create.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user)); mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); @@ -192,7 +197,7 @@ describe(AlbumService.name, () => { .asset({ id: assetId }, (asset) => asset.exif()) .albumUser(albumUser) .build(); - mocks.album.create.mockResolvedValue(album); + mocks.album.create.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(album.albumUsers[0].user); mocks.user.getMetadata.mockResolvedValue([ { @@ -250,7 +255,7 @@ describe(AlbumService.name, () => { .albumUser() .build(); mocks.user.get.mockResolvedValue(album.albumUsers[0].user); - mocks.album.create.mockResolvedValue(album); + mocks.album.create.mockResolvedValue(getForAlbum(album)); mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); @@ -316,7 +321,7 @@ describe(AlbumService.name, () => { it('should require a valid thumbnail asset id', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect( @@ -330,8 +335,8 @@ describe(AlbumService.name, () => { it('should allow the owner to update the album', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); - mocks.album.update.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.update.mockResolvedValue(getForAlbum(album)); await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }); @@ -352,7 +357,7 @@ describe(AlbumService.name, () => { it('should not let a shared user delete the album', async () => { const album = AlbumFactory.create(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException); @@ -363,7 +368,7 @@ describe(AlbumService.name, () => { it('should let the owner delete an album', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await sut.delete(AuthFactory.create(album.owner), album.id); @@ -387,7 +392,7 @@ describe(AlbumService.name, () => { const userId = newUuid(); const album = AlbumFactory.from().albumUser({ userId }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }), ).rejects.toBeInstanceOf(BadRequestException); @@ -398,7 +403,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId does not exist', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(void 0); await expect( sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }), @@ -410,7 +415,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId is the ownerId', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: album.owner.id }], @@ -424,8 +429,8 @@ describe(AlbumService.name, () => { const album = AlbumFactory.create(); const user = UserFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); - mocks.album.update.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.update.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build()); @@ -456,7 +461,7 @@ describe(AlbumService.name, () => { const userId = newUuid(); const album = AlbumFactory.from().albumUser({ userId }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.albumUser.delete.mockResolvedValue(); await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined(); @@ -470,7 +475,7 @@ describe(AlbumService.name, () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf( BadRequestException, @@ -483,7 +488,7 @@ describe(AlbumService.name, () => { it('should allow a shared user to remove themselves', async () => { const user1 = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user1.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.albumUser.delete.mockResolvedValue(); await sut.removeUser(AuthFactory.create(user1), album.id, user1.id); @@ -495,7 +500,7 @@ describe(AlbumService.name, () => { it('should allow a shared user to remove themselves using "me"', async () => { const user = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.albumUser.delete.mockResolvedValue(); await sut.removeUser(AuthFactory.create(user), album.id, 'me'); @@ -506,7 +511,7 @@ describe(AlbumService.name, () => { it('should not allow the owner to be removed', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf( BadRequestException, @@ -517,7 +522,7 @@ describe(AlbumService.name, () => { it('should throw an error for a user not in the album', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, @@ -546,7 +551,7 @@ describe(AlbumService.name, () => { describe('getAlbumInfo', () => { it('should get a shared album', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -566,7 +571,7 @@ describe(AlbumService.name, () => { it('should get a shared album via a shared link', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -588,7 +593,7 @@ describe(AlbumService.name, () => { it('should get a shared album via shared with user', async () => { const user = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -630,7 +635,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( @@ -654,7 +659,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([ @@ -675,7 +680,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( @@ -703,7 +708,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set()); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), @@ -718,7 +723,7 @@ describe(AlbumService.name, () => { const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build(); mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(auth, album.id, { ids: [asset1.id, asset2.id, asset3.id] })).resolves.toEqual([ @@ -742,7 +747,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -762,7 +767,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id])); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -776,7 +781,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -791,7 +796,7 @@ describe(AlbumService.name, () => { const user = UserFactory.create(); const album = AlbumFactory.create(); const asset = AssetFactory.create({ ownerId: user.id }); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf( BadRequestException, @@ -804,7 +809,7 @@ describe(AlbumService.name, () => { it('should not allow unauthorized shared link access to the album', async () => { const album = AlbumFactory.create(); const asset = AssetFactory.create(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }), @@ -821,7 +826,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -859,7 +864,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -897,7 +902,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -943,7 +948,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -965,7 +970,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build(); @@ -1004,7 +1009,7 @@ describe(AlbumService.name, () => { ]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -1048,7 +1053,7 @@ describe(AlbumService.name, () => { mocks.album.getAssetIds .mockResolvedValueOnce(new Set([asset1.id, asset2.id, asset3.id])) .mockResolvedValueOnce(new Set()); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); await expect( sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { @@ -1078,7 +1083,7 @@ describe(AlbumService.name, () => { .mockResolvedValueOnce(new Set([album1.id])) .mockResolvedValueOnce(new Set([album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); await expect( @@ -1107,7 +1112,7 @@ describe(AlbumService.name, () => { mocks.access.album.checkSharedAlbumAccess .mockResolvedValueOnce(new Set([album1.id])) .mockResolvedValueOnce(new Set([album2.id])); - mocks.album.getById.mockResolvedValueOnce(_.cloneDeep(album1)).mockResolvedValueOnce(_.cloneDeep(album2)); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -1138,7 +1143,7 @@ describe(AlbumService.name, () => { const album1 = AlbumFactory.create(); const album2 = AlbumFactory.create(); const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); await expect( sut.addAssetsToAlbums(AuthFactory.create(user), { @@ -1160,7 +1165,7 @@ describe(AlbumService.name, () => { const album1 = AlbumFactory.create(); const album2 = AlbumFactory.create(); const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); await expect( sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), { @@ -1182,7 +1187,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -1196,7 +1201,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -1210,7 +1215,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -1224,7 +1229,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id])); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([ diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 18747dbc3a..24b9b165c9 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -21,6 +21,7 @@ import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { asDateString } from 'src/utils/date'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -64,11 +65,11 @@ export class AlbumService extends BaseService { return albums.map((album) => ({ ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - startDate: albumMetadata[album.id]?.startDate ?? undefined, - endDate: albumMetadata[album.id]?.endDate ?? undefined, + startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined), + endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined), assetCount: albumMetadata[album.id]?.assetCount ?? 0, // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need - lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined, + lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined), })); } @@ -85,10 +86,10 @@ export class AlbumService extends BaseService { return { ...mapAlbum(album, withAssets, auth), - startDate: albumMetadataForIds?.startDate ?? undefined, - endDate: albumMetadataForIds?.endDate ?? undefined, + startDate: asDateString(albumMetadataForIds?.startDate ?? undefined), + endDate: asDateString(albumMetadataForIds?.endDate ?? undefined), assetCount: albumMetadataForIds?.assetCount ?? 0, - lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined, + lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined), contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined, }; } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 3a31dbbea1..68165d642f 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; -import { factory, newUuid } from 'test/small.factory'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ApiKeyService.name, () => { @@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => { }); it('should not require a name', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the api key does not have sufficient permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.AssetRead] }) + .build(); await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf( BadRequestException, @@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => { describe('update', () => { it('should throw an error if the key is not found', async () => { const id = newUuid(); - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => { }); it('should update a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newName = 'New name'; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => { }); it('should update permissions', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate]; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => { describe('api key auth', () => { it('should prevent adding Permission.all', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => { it('should prevent adding a new permission', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => { }); it('should allow removing permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } }); - const apiKey = factory.apiKey({ + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead, Permission.AssetDelete], }); @@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => { }); it('should allow adding new permissions', async () => { - const auth = factory.auth({ - apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }, - }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); @@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => { describe('delete', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => { }); it('should delete a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.delete.mockResolvedValue(); @@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => { describe('getMine', () => { it('should not work with a session token', async () => { - const session = factory.session(); - const auth = factory.auth({ session }); + const session = SessionFactory.create(); + const auth = AuthFactory.from().session(session).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the key is not found', async () => { - const apiKey = factory.authApiKey(); - const auth = factory.auth({ apiKey }); + const apiKey = ApiKeyFactory.create(); + const auth = AuthFactory.from().apiKey(apiKey).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => { describe('getById', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => { describe('getAll', () => { it('should return all the keys for a user', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getByUserId.mockResolvedValue([apiKey]); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 5fb45690cf..1bf8bafdf7 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -4,13 +4,12 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; -import { Stats } from 'node:fs'; import { AssetFile } from 'src/database'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; -import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetEditAction } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; +import { AssetFileType, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { UploadBody } from 'src/types'; @@ -22,6 +21,7 @@ import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { getForAsset } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); @@ -152,13 +152,6 @@ const createDto = Object.freeze({ duration: '0:00:00.000000', }) as AssetMediaCreateDto; -const replaceDto = Object.freeze({ - deviceAssetId: 'deviceAssetId', - deviceId: 'deviceId', - fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'), - fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'), -}) as AssetMediaReplaceDto; - const assetEntity = Object.freeze({ id: 'id_1', ownerId: 'user_id_1', @@ -170,7 +163,6 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - encodedVideoPath: '', duration: '0:00:00.000000', files: [] as AssetFile[], exifInfo: { @@ -180,25 +172,6 @@ const assetEntity = Object.freeze({ livePhotoVideoId: null, } as MapAsset); -const existingAsset = Object.freeze({ - ...assetEntity, - duration: null, - type: AssetType.Image, - checksum: Buffer.from('_getExistingAsset', 'utf8'), - libraryId: 'libraryId', - originalFileName: 'existing-filename.jpeg', -}) as MapAsset; - -const sidecarAsset = Object.freeze({ - ...existingAsset, - checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), -}) as MapAsset; - -const copiedAsset = Object.freeze({ - id: 'copied-asset', - originalPath: 'copied-path', -}) as MapAsset; - describe(AssetMediaService.name, () => { let sut: AssetMediaService; let mocks: ServiceMocks; @@ -434,7 +407,7 @@ describe(AssetMediaService.name, () => { .owner(authStub.user1.user) .build(); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); mocks.asset.create.mockResolvedValueOnce(asset); await expect( @@ -451,7 +424,7 @@ describe(AssetMediaService.name, () => { it('should hide the linked motion asset', async () => { const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build(); const asset = AssetFactory.create(); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); mocks.asset.create.mockResolvedValueOnce(asset); await expect( @@ -470,7 +443,7 @@ describe(AssetMediaService.name, () => { it('should handle a sidecar file', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); - mocks.asset.getById.mockResolvedValueOnce(asset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); mocks.asset.create.mockResolvedValueOnce(asset); await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ @@ -737,13 +710,18 @@ describe(AssetMediaService.name, () => { }); it('should return the encoded video path if available', async () => { - const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' }); + const asset = AssetFactory.from() + .file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' }) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: asset.files[0].path, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: asset.encodedVideoPath!, + path: '/path/to/encoded/video.mp4', cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), @@ -753,7 +731,10 @@ describe(AssetMediaService.name, () => { it('should fall back to the original path', async () => { const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: null, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ @@ -776,177 +757,6 @@ describe(AssetMediaService.name, () => { }); }); - describe('replaceAsset', () => { - it('should fail the auth check when update photo does not exist', async () => { - await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow( - 'Not found or no asset.update access', - ); - - expect(mocks.asset.create).not.toHaveBeenCalled(); - }); - - it('should fail if asset cannot be fetched', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); - await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, fileStub.photo)).rejects.toThrow( - 'Asset not found', - ); - - expect(mocks.asset.create).not.toHaveBeenCalled(); - }); - - it('should update a photo with no sidecar to photo with no sidecar', async () => { - const updatedFile = fileStub.photo; - const updatedAsset = { ...existingAsset, ...updatedFile }; - mocks.asset.getById.mockResolvedValueOnce(existingAsset); - mocks.asset.getById.mockResolvedValueOnce(updatedAsset); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); - // this is the original file size - mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); - // this is for the clone call - mocks.asset.create.mockResolvedValue(copiedAsset); - - await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ - status: AssetMediaStatus.REPLACED, - id: 'copied-asset', - }); - - expect(mocks.asset.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: existingAsset.id, - originalFileName: 'photo1.jpeg', - originalPath: 'fake_path/photo1.jpeg', - }), - ); - expect(mocks.asset.create).toHaveBeenCalledWith( - expect.objectContaining({ - originalFileName: 'existing-filename.jpeg', - originalPath: 'fake_path/asset_1.jpeg', - }), - ); - expect(mocks.asset.deleteFile).toHaveBeenCalledWith( - expect.objectContaining({ - assetId: existingAsset.id, - type: AssetFileType.Sidecar, - }), - ); - - expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { - deletedAt: expect.any(Date), - status: AssetStatus.Trashed, - }); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(mocks.storage.utimes).toHaveBeenCalledWith( - updatedFile.originalPath, - expect.any(Date), - new Date(replaceDto.fileModifiedAt), - ); - }); - - it('should update a photo with sidecar to photo with sidecar', async () => { - const updatedFile = fileStub.photo; - const sidecarFile = fileStub.photoSidecar; - const updatedAsset = { ...sidecarAsset, ...updatedFile }; - mocks.asset.getById.mockResolvedValueOnce(existingAsset); - mocks.asset.getById.mockResolvedValueOnce(updatedAsset); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); - // this is the original file size - mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); - // this is for the clone call - mocks.asset.create.mockResolvedValue(copiedAsset); - - await expect( - sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile), - ).resolves.toEqual({ - status: AssetMediaStatus.REPLACED, - id: 'copied-asset', - }); - - expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { - deletedAt: expect.any(Date), - status: AssetStatus.Trashed, - }); - expect(mocks.asset.upsertFile).toHaveBeenCalledWith( - expect.objectContaining({ - assetId: existingAsset.id, - path: sidecarFile.originalPath, - type: AssetFileType.Sidecar, - }), - ); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(mocks.storage.utimes).toHaveBeenCalledWith( - updatedFile.originalPath, - expect.any(Date), - new Date(replaceDto.fileModifiedAt), - ); - }); - - it('should update a photo with a sidecar to photo with no sidecar', async () => { - const updatedFile = fileStub.photo; - - const updatedAsset = { ...sidecarAsset, ...updatedFile }; - mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); - mocks.asset.getById.mockResolvedValueOnce(updatedAsset); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); - // this is the original file size - mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); - // this is for the copy call - mocks.asset.create.mockResolvedValue(copiedAsset); - - await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ - status: AssetMediaStatus.REPLACED, - id: 'copied-asset', - }); - - expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { - deletedAt: expect.any(Date), - status: AssetStatus.Trashed, - }); - expect(mocks.asset.deleteFile).toHaveBeenCalledWith( - expect.objectContaining({ - assetId: existingAsset.id, - type: AssetFileType.Sidecar, - }), - ); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(mocks.storage.utimes).toHaveBeenCalledWith( - updatedFile.originalPath, - expect.any(Date), - new Date(replaceDto.fileModifiedAt), - ); - }); - - it('should handle a photo with sidecar to duplicate photo ', async () => { - const updatedFile = fileStub.photo; - const error = new Error('unique key violation'); - (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - - mocks.asset.update.mockRejectedValue(error); - mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); - mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); - // this is the original file size - mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); - // this is for the clone call - mocks.asset.create.mockResolvedValue(copiedAsset); - - await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({ - status: AssetMediaStatus.DUPLICATE, - id: sidecarAsset.id, - }); - - expect(mocks.asset.create).not.toHaveBeenCalled(); - expect(mocks.asset.updateAll).not.toHaveBeenCalled(); - expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); - expect(mocks.asset.deleteFile).not.toHaveBeenCalled(); - - expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.FileDelete, - data: { files: [updatedFile.originalPath, undefined] }, - }); - expect(mocks.user.updateUsage).not.toHaveBeenCalled(); - }); - }); - describe('bulkUploadCheck', () => { it('should accept hex and base64 checksums', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 020bda4df7..3c981ea61e 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -151,6 +151,10 @@ export class AssetMediaService extends BaseService { } const asset = await this.create(auth.user.id, dto, file, sidecarFile); + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]); + } + await this.userRepository.updateUsage(auth.user.id, file.size); return { id: asset.id, status: AssetMediaStatus.CREATED }; @@ -341,6 +345,11 @@ export class AssetMediaService extends BaseService { this.logger.error(`Error locating duplicate for checksum constraint`); throw new InternalServerErrorException(); } + + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); + } + return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index cc8603cc5a..718ec00f1d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -7,7 +7,9 @@ import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -71,7 +73,7 @@ describe(AssetService.name, () => { describe('getRandom', () => { it('should get own random assets', async () => { mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); + mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); await sut.getRandom(authStub.admin, 1); @@ -79,11 +81,11 @@ describe(AssetService.name, () => { }); it('should not include partner assets if not in timeline', async () => { - const partner = factory.partner({ inTimeline: false }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: false }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); - mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); await sut.getRandom(auth, 1); @@ -91,11 +93,11 @@ describe(AssetService.name, () => { }); it('should include partner assets if in timeline', async () => { - const partner = factory.partner({ inTimeline: true }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: true }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); - mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); await sut.getRandom(auth, 1); @@ -107,7 +109,7 @@ describe(AssetService.name, () => { it('should allow owner access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.admin, asset.id); @@ -121,7 +123,7 @@ describe(AssetService.name, () => { it('should allow shared link access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.adminSharedLink, asset.id); @@ -134,7 +136,7 @@ describe(AssetService.name, () => { it('should strip metadata for shared link if exif is disabled', async () => { const asset = AssetFactory.from().exif({ description: 'foo' }).build(); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -152,7 +154,7 @@ describe(AssetService.name, () => { it('should allow partner sharing access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.admin, asset.id); @@ -162,7 +164,7 @@ describe(AssetService.name, () => { it('should allow shared album access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.admin, asset.id); @@ -204,8 +206,8 @@ describe(AssetService.name, () => { it('should update the asset', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); - mocks.asset.update.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); + mocks.asset.update.mockResolvedValue(getForAsset(asset)); await sut.update(authStub.admin, asset.id, { isFavorite: true }); @@ -215,8 +217,8 @@ describe(AssetService.name, () => { it('should update the exif description', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); - mocks.asset.update.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); + mocks.asset.update.mockResolvedValue(getForAsset(asset)); await sut.update(authStub.admin, asset.id, { description: 'Test description' }); @@ -229,8 +231,8 @@ describe(AssetService.name, () => { it('should update the exif rating', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValueOnce(asset); - mocks.asset.update.mockResolvedValueOnce(asset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); + mocks.asset.update.mockResolvedValueOnce(getForAsset(asset)); await sut.update(authStub.admin, asset.id, { rating: 3 }); @@ -274,7 +276,7 @@ describe(AssetService.name, () => { const motionAsset = AssetFactory.from().owner(auth.user).build(); const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await expect( sut.update(authStub.admin, asset.id, { @@ -301,7 +303,7 @@ describe(AssetService.name, () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(motionAsset); + mocks.asset.getById.mockResolvedValue(getForAsset(motionAsset)); await expect( sut.update(auth, asset.id, { @@ -327,9 +329,9 @@ describe(AssetService.name, () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline }); const stillAsset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id])); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); - mocks.asset.getById.mockResolvedValueOnce(stillAsset); - mocks.asset.update.mockResolvedValue(stillAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(stillAsset)); + mocks.asset.update.mockResolvedValue(getForAsset(stillAsset)); const auth = AuthFactory.from(motionAsset.owner).build(); await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id }); @@ -354,9 +356,9 @@ describe(AssetService.name, () => { const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); const unlinkedAsset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValueOnce(asset); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); - mocks.asset.update.mockResolvedValueOnce(unlinkedAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); + mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset)); await sut.update(auth, asset.id, { livePhotoVideoId: null }); @@ -532,7 +534,7 @@ describe(AssetService.name, () => { }); it('should immediately queue assets for deletion if trash is disabled', async () => { - const asset = factory.asset({ isOffline: false }); + const asset = AssetFactory.create(); mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset])); mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); @@ -546,7 +548,7 @@ describe(AssetService.name, () => { }); it('should queue assets for deletion after trash duration', async () => { - const asset = factory.asset({ isOffline: false }); + const asset = AssetFactory.create(); mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset])); mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); @@ -569,7 +571,7 @@ describe(AssetService.name, () => { .file({ type: AssetFileType.Preview, isEdited: true }) .file({ type: AssetFileType.Thumbnail, isEdited: true }) .build(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -583,7 +585,7 @@ describe(AssetService.name, () => { }, ], ]); - expect(mocks.asset.remove).toHaveBeenCalledWith(asset); + expect(mocks.asset.remove).toHaveBeenCalledWith(getForAssetDeletion(asset)); }); it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { @@ -591,11 +593,7 @@ describe(AssetService.name, () => { .stack({}, (builder) => builder.asset()) .build(); mocks.stack.delete.mockResolvedValue(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue({ - ...asset, - // TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually - stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) }, - }); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -605,7 +603,7 @@ describe(AssetService.name, () => { it('should delete a live photo', async () => { const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build(); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); mocks.asset.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ @@ -622,7 +620,7 @@ describe(AssetService.name, () => { it('should not delete a live motion part if it is being used by another asset', async () => { const asset = AssetFactory.create({ livePhotoVideoId: newUuid() }); mocks.asset.getLivePhotoCount.mockResolvedValue(2); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -633,7 +631,7 @@ describe(AssetService.name, () => { it('should update usage', async () => { const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000); }); @@ -739,7 +737,7 @@ describe(AssetService.name, () => { describe('upsertMetadata', () => { it('should throw a bad request exception if duplicate keys are sent', async () => { - const asset = factory.asset(); + const asset = AssetFactory.create(); const items = [ { key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, { key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, @@ -757,7 +755,7 @@ describe(AssetService.name, () => { describe('upsertBulkMetadata', () => { it('should throw a bad request exception if duplicate keys are sent', async () => { - const asset = factory.asset(); + const asset = AssetFactory.create(); const items = [ { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 387b700f01..1e5d23a98d 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -370,7 +370,7 @@ export class AssetService extends BaseService { assetFiles.editedFullsizeFile?.path, assetFiles.editedPreviewFile?.path, assetFiles.editedThumbnailFile?.path, - asset.encodedVideoPath, + assetFiles.encodedVideoFile?.path, ]; if (deleteOnDisk && !asset.isOffline) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 81f601da0a..f2cc3ada95 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; import { UserMetadataItem } from 'src/types'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory, newUuid } from 'test/small.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const oauthResponse = ({ @@ -91,8 +95,8 @@ describe(AuthService.name, () => { }); it('should successfully log the user in', async () => { - const user = { ...(factory.user() as UserAdmin), password: 'immich_password' }; - const session = factory.session(); + const user = UserFactory.create({ password: 'immich_password' }); + const session = SessionFactory.create(); mocks.user.getByEmail.mockResolvedValue(user); mocks.session.create.mockResolvedValue(session); @@ -113,8 +117,8 @@ describe(AuthService.name, () => { describe('changePassword', () => { it('should change the password', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -132,8 +136,8 @@ describe(AuthService.name, () => { }); it('should throw when password does not match existing password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.crypto.compareBcrypt.mockReturnValue(false); @@ -144,8 +148,8 @@ describe(AuthService.name, () => { }); it('should throw when user does not have a password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' }); @@ -154,8 +158,8 @@ describe(AuthService.name, () => { }); it('should change the password and logout other sessions', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -175,7 +179,7 @@ describe(AuthService.name, () => { describe('logout', () => { it('should return the end session endpoint', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -186,7 +190,7 @@ describe(AuthService.name, () => { }); it('should return the default redirect', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, @@ -262,11 +266,11 @@ describe(AuthService.name, () => { }); it('should validate using authorization header', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -340,7 +344,7 @@ describe(AuthService.name, () => { }); it('should accept a base64url key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -361,7 +365,7 @@ describe(AuthService.name, () => { }); it('should accept a hex key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -396,7 +400,7 @@ describe(AuthService.name, () => { }); it('should accept a valid slug', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any; mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink); @@ -428,11 +432,11 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -455,11 +459,11 @@ describe(AuthService.name, () => { }); it('should throw if admin route and not an admin', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -477,11 +481,11 @@ describe(AuthService.name, () => { }); it('should update when access time exceeds an hour', async () => { - const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); + const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -517,8 +521,8 @@ describe(AuthService.name, () => { }); it('should throw an error if api key has insufficient permissions', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -533,8 +537,8 @@ describe(AuthService.name, () => { }); it('should default to requiring the all permission when omitted', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -548,10 +552,12 @@ describe(AuthService.name, () => { }); it('should not require any permission when metadata is set to `false`', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); const result = sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -562,10 +568,12 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); await expect( sut.authenticate({ @@ -629,12 +637,12 @@ describe(AuthService.name, () => { }); it('should link an existing user', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.user.getByEmail.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -649,7 +657,7 @@ describe(AuthService.name, () => { }); it('should not link to a user with a different oauth sub', async () => { - const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' }); + const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.user.getByEmail.mockResolvedValueOnce(user); @@ -669,13 +677,13 @@ describe(AuthService.name, () => { }); it('should allow auto registering by default', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -690,13 +698,13 @@ describe(AuthService.name, () => { }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - const user = factory.userAdmin({ isAdmin: true }); + const user = UserFactory.create({ isAdmin: true }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect( @@ -717,11 +725,11 @@ describe(AuthService.name, () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.user.getByOAuthId.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails); @@ -735,13 +743,13 @@ describe(AuthService.name, () => { } it('should use the default quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -755,14 +763,14 @@ describe(AuthService.name, () => { }); it('should ignore an invalid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -776,14 +784,14 @@ describe(AuthService.name, () => { }); it('should ignore a negative quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 }); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -797,14 +805,14 @@ describe(AuthService.name, () => { }); it('should set quota for 0 quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -825,15 +833,15 @@ describe(AuthService.name, () => { }); it('should use a valid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -855,7 +863,7 @@ describe(AuthService.name, () => { it('should sync the profile picture', async () => { const fileId = newUuid(); - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg'; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); @@ -871,7 +879,7 @@ describe(AuthService.name, () => { data: new Uint8Array([1, 2, 3, 4, 5]).buffer, }); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -889,7 +897,7 @@ describe(AuthService.name, () => { }); it('should not sync the profile picture if the user already has one', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); + const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.oauth.getProfile.mockResolvedValue({ @@ -899,7 +907,7 @@ describe(AuthService.name, () => { }); mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -914,15 +922,15 @@ describe(AuthService.name, () => { }); it('should only allow "admin" and "user" for the role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -943,14 +951,14 @@ describe(AuthService.name, () => { }); it('should create an admin user if the role claim is set to admin', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' }); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -971,7 +979,7 @@ describe(AuthService.name, () => { }); it('should accept a custom role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue({ oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' }, @@ -980,7 +988,7 @@ describe(AuthService.name, () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -1003,8 +1011,8 @@ describe(AuthService.name, () => { describe('link', () => { it('should link an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ apiKey: { permissions: [] }, user }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1019,8 +1027,8 @@ describe(AuthService.name, () => { }); it('should not link an already linked oauth.sub', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); const auth = { user: authUser, apiKey: authApiKey }; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -1036,8 +1044,8 @@ describe(AuthService.name, () => { describe('unlink', () => { it('should unlink an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user, apiKey: { permissions: [] } }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1050,8 +1058,8 @@ describe(AuthService.name, () => { describe('setupPinCode', () => { it('should setup a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' }); @@ -1065,8 +1073,8 @@ describe(AuthService.name, () => { }); it('should fail if the user already has a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1076,8 +1084,8 @@ describe(AuthService.name, () => { describe('changePinCode', () => { it('should change the PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456', newPinCode: '012345' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1091,37 +1099,37 @@ describe(AuthService.name, () => { }); it('should fail if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); await expect( - sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }), + sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }), ).rejects.toThrow('Wrong PIN code'); }); }); describe('resetPinCode', () => { it('should reset the PIN code', async () => { - const currentSession = factory.session(); - const user = factory.userAdmin(); + const currentSession = SessionFactory.create(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); - await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); + await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); + await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); }); }); }); diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 36a3d2eb2c..347d9eef00 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,7 +1,7 @@ import { jwtVerify } from 'jose'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { CliService } from 'src/services/cli.service'; -import { factory } from 'test/small.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe, it } from 'vitest'; @@ -15,7 +15,7 @@ describe(CliService.name, () => { describe('listUsers', () => { it('should list users', async () => { - mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]); + mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); @@ -32,10 +32,10 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); - mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); const ask = vitest.fn().mockImplementation(() => {}); @@ -50,7 +50,7 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 0b216e8b8a..38c6833105 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -3,6 +3,7 @@ import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForDuplicate } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; @@ -39,11 +40,11 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { - const asset = AssetFactory.create(); + const asset = AssetFactory.from().exif().build(); mocks.duplicateRepository.getAll.mockResolvedValue([ { duplicateId: 'duplicate-id', - assets: [asset, asset], + assets: [getForDuplicate(asset), getForDuplicate(asset)], }, ]); await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 7c9581ff9a..98f369c31a 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -186,8 +186,8 @@ export class JobService extends BaseService { exifImageHeight: exif.exifImageHeight, fileSizeInByte: exif.fileSizeInByte, orientation: exif.orientation, - dateTimeOriginal: exif.dateTimeOriginal, - modifyDate: exif.modifyDate, + dateTimeOriginal: exif.dateTimeOriginal ? new Date(exif.dateTimeOriginal) : null, + modifyDate: exif.modifyDate ? new Date(exif.modifyDate) : null, timeZone: exif.timeZone, latitude: exif.latitude, longitude: exif.longitude, diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index d58ae67140..fdf7aee68b 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -2,8 +2,9 @@ import { MapService } from 'src/services/map.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; +import { getForAlbum, getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { @@ -39,7 +40,7 @@ describe(MapService.name, () => { it('should include partner assets', async () => { const auth = AuthFactory.create(); - const partner = factory.partner({ sharedWithId: auth.user.id }); + const partner = PartnerFactory.create({ sharedWithId: auth.user.id }); const asset = AssetFactory.from() .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) @@ -52,7 +53,7 @@ describe(MapService.name, () => { state: asset.exifInfo.state, country: asset.exifInfo.country, }; - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(auth, { withPartners: true }); @@ -81,8 +82,10 @@ describe(MapService.name, () => { }; mocks.partner.getAll.mockResolvedValue([]); mocks.map.getMapMarkers.mockResolvedValue([marker]); - mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]); - mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]); + mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]); + mocks.album.getShared.mockResolvedValue([ + getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()), + ]); const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index cd61d7b45b..51a10a39c2 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,3 +1,4 @@ +import { ShallowDehydrateObject } from 'kysely'; import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; @@ -27,6 +28,7 @@ import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; import { personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; +import { getForGenerateThumbnail } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -367,8 +369,10 @@ describe(MediaService.name, () => { }); it('should skip thumbnail generation if asset type is unknown', async () => { - const asset = AssetFactory.create({ type: 'foo' as AssetType }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + const asset = AssetFactory.from({ type: 'foo' as AssetType }) + .exif() + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.media.probe).not.toHaveBeenCalled(); @@ -377,17 +381,17 @@ describe(MediaService.name, () => { }); it('should skip video thumbnail generation if no video stream', async () => { - const asset = AssetFactory.create({ type: AssetType.Video }); + const asset = AssetFactory.from({ type: AssetType.Video }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await expect(sut.handleGenerateThumbnails({ id: asset.id })).rejects.toThrowError(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }).exif().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); expect(await sut.handleGenerateThumbnails({ id: asset.id })).toEqual(JobStatus.Skipped); @@ -398,7 +402,7 @@ describe(MediaService.name, () => { it('should delete previous preview if different path', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -415,7 +419,7 @@ describe(MediaService.name, () => { .exif({ profileDescription: 'Adobe RGB', bitsPerSample: 14 }) .files([AssetFileType.Preview, AssetFileType.Thumbnail]) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); @@ -490,9 +494,9 @@ describe(MediaService.name, () => { }); it('should generate a thumbnail for a video', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); @@ -532,9 +536,9 @@ describe(MediaService.name, () => { }); it('should tonemap thumbnail for hdr video', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); @@ -574,12 +578,12 @@ describe(MediaService.name, () => { }); it('should always generate video thumbnail in one pass', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -600,9 +604,9 @@ describe(MediaService.name, () => { }); it('should not skip intra frames for MTS file', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -618,9 +622,9 @@ describe(MediaService.name, () => { }); it('should override reserved color metadata', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamReserved); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -638,10 +642,10 @@ describe(MediaService.name, () => { }); it('should use scaling divisible by 2 even when using quick sync', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -658,7 +662,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.${format}`; @@ -708,7 +712,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`; @@ -760,7 +764,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: false } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -799,7 +803,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -834,12 +838,12 @@ describe(MediaService.name, () => { }); it('should never set isProgressive for videos', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -860,7 +864,7 @@ describe(MediaService.name, () => { it('should delete previous thumbnail if different path', async () => { const asset = AssetFactory.from().exif().file({ type: AssetFileType.Preview }).build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -879,7 +883,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -896,7 +900,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -910,7 +914,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -925,7 +929,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -941,7 +945,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -958,7 +962,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -977,7 +981,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1018,7 +1022,7 @@ describe(MediaService.name, () => { }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1056,7 +1060,7 @@ describe(MediaService.name, () => { }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1104,7 +1108,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1156,7 +1160,7 @@ describe(MediaService.name, () => { bitsPerSample: 14, }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1187,7 +1191,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1219,7 +1223,7 @@ describe(MediaService.name, () => { }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1264,7 +1268,7 @@ describe(MediaService.name, () => { bitsPerSample: 14, }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1303,7 +1307,7 @@ describe(MediaService.name, () => { bitsPerSample: 14, }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1338,7 +1342,7 @@ describe(MediaService.name, () => { it('should skip videos', async () => { const asset = AssetFactory.from({ type: AssetType.Video }).exif().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); @@ -1355,7 +1359,7 @@ describe(MediaService.name, () => { ]) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); @@ -1377,7 +1381,7 @@ describe(MediaService.name, () => { .exif() .edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); @@ -1405,7 +1409,7 @@ describe(MediaService.name, () => { { type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true }, ]) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); @@ -1423,7 +1427,7 @@ describe(MediaService.name, () => { it('should generate all 3 edited files if an asset has edits', async () => { const asset = AssetFactory.from().exif().edit().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); @@ -1449,7 +1453,7 @@ describe(MediaService.name, () => { it('should generate the original thumbhash if no edits exist', async () => { const asset = AssetFactory.from().exif().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' }); @@ -1459,7 +1463,7 @@ describe(MediaService.name, () => { it('should apply thumbhash if job source is edit and edits exist', async () => { const asset = AssetFactory.from().exif().edit().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = factory.buffer(); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); @@ -2250,7 +2254,9 @@ describe(MediaService.name, () => { }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' }); + const asset = AssetFactory.from({ type: AssetType.Video }) + .file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' }) + .build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); @@ -2260,7 +2266,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, - data: { files: [asset.encodedVideoPath] }, + data: { files: ['/encoded/video/path.mp4'] }, }); }); @@ -3603,15 +3609,15 @@ describe(MediaService.name, () => { describe('isSRGB', () => { it('should return true for srgb colorspace', () => { - expect(sut.isSRGB({ colorspace: 'sRGB' } as Exif)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB' } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for srgb profile description', () => { - expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as Exif)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for 8-bit image with no colorspace metadata', () => { - expect(sut.isSRGB({ bitsPerSample: 8 } as Exif)).toEqual(true); + expect(sut.isSRGB({ bitsPerSample: 8 } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for image with no colorspace or bit depth metadata', () => { @@ -3619,23 +3625,25 @@ describe(MediaService.name, () => { }); it('should return false for non-srgb colorspace', () => { - expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as Exif)).toEqual(false); + expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as ShallowDehydrateObject)).toEqual(false); }); it('should return false for non-srgb profile description', () => { - expect(sut.isSRGB({ profileDescription: 'sP3C' } as Exif)).toEqual(false); + expect(sut.isSRGB({ profileDescription: 'sP3C' } as ShallowDehydrateObject)).toEqual(false); }); it('should return false for 16-bit image with no colorspace metadata', () => { - expect(sut.isSRGB({ bitsPerSample: 16 } as Exif)).toEqual(false); + expect(sut.isSRGB({ bitsPerSample: 16 } as ShallowDehydrateObject)).toEqual(false); }); it('should return true for 16-bit image with sRGB colorspace', () => { - expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for 16-bit image with sRGB profile', () => { - expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject)).toEqual( + true, + ); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index e49b8c10af..ea0b1e9142 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; -import { AssetFile, Exif } from 'src/database'; +import { AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; @@ -39,7 +39,7 @@ import { VideoInterfaces, VideoStreamInfo, } from 'src/types'; -import { getDimensions } from 'src/utils/asset.util'; +import { getAssetFile, getDimensions } from 'src/utils/asset.util'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; @@ -258,7 +258,7 @@ export class MediaService extends BaseService { return extracted; } - private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) { + private async decodeImage(thumbSource: string | Buffer, exifInfo: ThumbnailAsset['exifInfo'], targetSize?: number) { const { image } = await this.getConfig({ withCache: true }); const colorspace = this.isSRGB(exifInfo) ? Colorspace.Srgb : image.colorspace; const decodeOptions: DecodeToBufferOptions = { @@ -605,10 +605,11 @@ export class MediaService extends BaseService { let { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream); if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) { - if (asset.encodedVideoPath) { + const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); + if (encodedVideo) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); - await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } }); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } }); + await this.assetRepository.deleteFiles([encodedVideo]); } else { this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } @@ -656,7 +657,12 @@ export class MediaService extends BaseService { this.logger.log(`Successfully encoded ${asset.id}`); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: output }); + await this.assetRepository.upsertFile({ + assetId: asset.id, + type: AssetFileType.EncodedVideo, + path: output, + isEdited: false, + }); return JobStatus.Success; } @@ -754,7 +760,15 @@ export class MediaService extends BaseService { return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name); } - isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean { + isSRGB({ + colorspace, + profileDescription, + bitsPerSample, + }: { + colorspace: string | null; + profileDescription: string | null; + bitsPerSample: number | null; + }): boolean { if (colorspace || profileDescription) { return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb')); } else if (bitsPerSample) { diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 44929f2bbf..0445cf892b 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,6 +1,9 @@ import { BadRequestException } from '@nestjs/common'; import { MemoryService } from 'src/services/memory.service'; import { OnThisDayData } from 'src/types'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { MemoryFactory } from 'test/factories/memory.factory'; +import { getForMemory } from 'test/mappers'; import { factory, newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -27,11 +30,11 @@ describe(MemoryService.name, () => { describe('search', () => { it('should search memories', async () => { const [userId] = newUuids(); - const asset = factory.asset(); - const memory1 = factory.memory({ ownerId: userId, assets: [asset] }); - const memory2 = factory.memory({ ownerId: userId }); + const asset = AssetFactory.create(); + const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); + const memory2 = MemoryFactory.create({ ownerId: userId }); - mocks.memory.search.mockResolvedValue([memory1, memory2]); + mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]); await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( expect.arrayContaining([ @@ -64,9 +67,9 @@ describe(MemoryService.name, () => { it('should get a memory by id', async () => { const userId = newUuid(); - const memory = factory.memory({ ownerId: userId }); + const memory = MemoryFactory.create({ ownerId: userId }); - mocks.memory.get.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); await expect(sut.get(factory.auth({ user: { id: userId } }), memory.id)).resolves.toMatchObject({ @@ -81,9 +84,9 @@ describe(MemoryService.name, () => { describe('create', () => { it('should skip assets the user does not have access to', async () => { const [assetId, userId] = newUuids(); - const memory = factory.memory({ ownerId: userId }); + const memory = MemoryFactory.create({ ownerId: userId }); - mocks.memory.create.mockResolvedValue(memory); + mocks.memory.create.mockResolvedValue(getForMemory(memory)); await expect( sut.create(factory.auth({ user: { id: userId } }), { @@ -109,11 +112,11 @@ describe(MemoryService.name, () => { it('should create a memory', async () => { const [assetId, userId] = newUuids(); - const asset = factory.asset({ id: assetId, ownerId: userId }); - const memory = factory.memory({ assets: [asset] }); + const asset = AssetFactory.create({ id: assetId, ownerId: userId }); + const memory = MemoryFactory.from().asset(asset).build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.memory.create.mockResolvedValue(memory); + mocks.memory.create.mockResolvedValue(getForMemory(memory)); await expect( sut.create(factory.auth({ user: { id: userId } }), { @@ -131,9 +134,9 @@ describe(MemoryService.name, () => { }); it('should create a memory without assets', async () => { - const memory = factory.memory(); + const memory = MemoryFactory.create(); - mocks.memory.create.mockResolvedValue(memory); + mocks.memory.create.mockResolvedValue(getForMemory(memory)); await expect( sut.create(factory.auth(), { @@ -155,10 +158,10 @@ describe(MemoryService.name, () => { }); it('should update a memory', async () => { - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(getForMemory(memory)); await expect(sut.update(factory.auth(), memory.id, { isSaved: true })).resolves.toBeDefined(); @@ -198,10 +201,10 @@ describe(MemoryService.name, () => { it('should require asset access', async () => { const assetId = newUuid(); - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.get.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); mocks.memory.getAssetIds.mockResolvedValue(new Set()); await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([ @@ -212,11 +215,11 @@ describe(MemoryService.name, () => { }); it('should skip assets already in the memory', async () => { - const asset = factory.asset(); - const memory = factory.memory({ assets: [asset] }); + const asset = AssetFactory.create(); + const memory = MemoryFactory.from().asset(asset).build(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.get.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); await expect(sut.addAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([ @@ -228,12 +231,12 @@ describe(MemoryService.name, () => { it('should add assets', async () => { const assetId = newUuid(); - const memory = factory.memory(); + const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - mocks.memory.get.mockResolvedValue(memory); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); + mocks.memory.update.mockResolvedValue(getForMemory(memory)); mocks.memory.getAssetIds.mockResolvedValue(new Set()); mocks.memory.addAssetIds.mockResolvedValue(); @@ -266,14 +269,14 @@ describe(MemoryService.name, () => { }); it('should remove assets', async () => { - const memory = factory.memory(); - const asset = factory.asset(); + const memory = MemoryFactory.create(); + const asset = AssetFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); mocks.memory.removeAssetIds.mockResolvedValue(); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(getForMemory(memory)); await expect(sut.removeAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([ { id: asset.id, success: true }, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 92ec13bea5..19dd1e5352 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -19,6 +19,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; import { tagStub } from 'test/fixtures/tag.stub'; +import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -176,7 +177,7 @@ describe(MetadataService.name, () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -198,7 +199,7 @@ describe(MetadataService.name, () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -228,7 +229,7 @@ describe(MetadataService.name, () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -257,7 +258,7 @@ describe(MetadataService.name, () => { it('should determine dateTimeOriginal regardless of the server time zone', async () => { process.env.TZ = 'America/Los_Angeles'; const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -277,7 +278,7 @@ describe(MetadataService.name, () => { it('should handle lists of numbers', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: asset.fileModifiedAt, @@ -305,7 +306,7 @@ describe(MetadataService.name, () => { it('should not delete latituide and longitude without reverse geocode', async () => { // regression test for issue 17511 const asset = AssetFactory.from().exif().build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); mocks.storage.stat.mockResolvedValue({ size: 123_456, @@ -337,7 +338,7 @@ describe(MetadataService.name, () => { it('should apply reverse geocoding', async () => { const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.storage.stat.mockResolvedValue({ @@ -367,7 +368,7 @@ describe(MetadataService.name, () => { it('should discard latitude and longitude on null island', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, @@ -383,7 +384,7 @@ describe(MetadataService.name, () => { it('should extract tags from TagsList', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -395,7 +396,7 @@ describe(MetadataService.name, () => { it('should extract hierarchy from TagsList', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -417,7 +418,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a string', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -429,7 +430,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -441,7 +442,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list with a number', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -454,7 +455,7 @@ describe(MetadataService.name, () => { it('should extract hierarchal tags from Keywords', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -474,7 +475,7 @@ describe(MetadataService.name, () => { it('should ignore Keywords when TagsList is present', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Child'] }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -495,7 +496,7 @@ describe(MetadataService.name, () => { it('should extract hierarchy from HierarchicalSubject', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'TagA'] }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -522,7 +523,7 @@ describe(MetadataService.name, () => { it('should extract tags from HierarchicalSubject as a list with a number', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -535,7 +536,7 @@ describe(MetadataService.name, () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -551,7 +552,7 @@ describe(MetadataService.name, () => { it('should ignore HierarchicalSubject when TagsList is present', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -572,7 +573,7 @@ describe(MetadataService.name, () => { it('should remove existing tags', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({}); await sut.handleMetadataExtraction({ id: asset.id }); @@ -582,7 +583,7 @@ describe(MetadataService.name, () => { it('should not apply motion photos if asset is video', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: asset.id }); @@ -597,7 +598,7 @@ describe(MetadataService.name, () => { it('should handle an invalid Directory Item', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], @@ -608,7 +609,7 @@ describe(MetadataService.name, () => { it('should extract the correct video orientation', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); @@ -624,7 +625,7 @@ describe(MetadataService.name, () => { it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { const asset = AssetFactory.create(); const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: asset.fileModifiedAt, @@ -686,7 +687,7 @@ describe(MetadataService.name, () => { mtimeMs: asset.fileModifiedAt.valueOf(), birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), @@ -733,7 +734,7 @@ describe(MetadataService.name, () => { it('should extract the motion photo video from the XMP directory entry ', async () => { const asset = AssetFactory.create(); const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: asset.fileModifiedAt, @@ -786,7 +787,7 @@ describe(MetadataService.name, () => { it('should delete old motion photo video assets if they do not match what is extracted', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -808,7 +809,7 @@ describe(MetadataService.name, () => { it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -832,7 +833,7 @@ describe(MetadataService.name, () => { it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -859,7 +860,7 @@ describe(MetadataService.name, () => { it('should not update storage usage if motion photo is external', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); const asset = AssetFactory.create({ isExternal: true }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -904,7 +905,7 @@ describe(MetadataService.name, () => { Rating: 3, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(tags); await sut.handleMetadataExtraction({ id: asset.id }); @@ -969,7 +970,7 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), zone: undefined, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(tags); await sut.handleMetadataExtraction({ id: asset.id }); @@ -984,7 +985,7 @@ describe(MetadataService.name, () => { it('should extract duration', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1007,7 +1008,7 @@ describe(MetadataService.name, () => { it('should only extract duration for videos', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1029,7 +1030,7 @@ describe(MetadataService.name, () => { it('should omit duration of zero', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1052,7 +1053,7 @@ describe(MetadataService.name, () => { it('should a handle duration of 1 week', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1075,7 +1076,7 @@ describe(MetadataService.name, () => { it('should use Duration from exif', async () => { const asset = AssetFactory.create({ originalFileName: 'file.webp' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, {}); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1086,7 +1087,7 @@ describe(MetadataService.name, () => { it('should prefer Duration from exif over sidecar', async () => { const asset = AssetFactory.from({ originalFileName: 'file.webp' }).file({ type: AssetFileType.Sidecar }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, { Duration: 456 }); @@ -1098,7 +1099,7 @@ describe(MetadataService.name, () => { it('should ignore all Duration tags for definitely static images', async () => { const asset = AssetFactory.from({ originalFileName: 'file.dng' }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, { Duration: 456 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1109,7 +1110,7 @@ describe(MetadataService.name, () => { it('should ignore Duration from exif for videos', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, {}); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, @@ -1127,7 +1128,7 @@ describe(MetadataService.name, () => { it('should trim whitespace from description', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1150,7 +1151,7 @@ describe(MetadataService.name, () => { it('should handle a numeric description', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Description: 1000 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1164,7 +1165,7 @@ describe(MetadataService.name, () => { it('should skip importing metadata when the feature is disabled', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(makeFaceTags({ Name: 'Person 1' })); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1173,7 +1174,7 @@ describe(MetadataService.name, () => { it('should skip importing metadata face for assets without tags.RegionInfo', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1182,7 +1183,7 @@ describe(MetadataService.name, () => { it('should skip importing faces without name', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags()); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1195,7 +1196,7 @@ describe(MetadataService.name, () => { it('should skip importing faces with empty name', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: '' })); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1210,7 +1211,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create(); const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name })); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1252,7 +1253,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create(); const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + 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 }]); @@ -1339,7 +1340,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create(); const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1383,7 +1384,7 @@ describe(MetadataService.name, () => { it('should handle invalid modify date', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ModifyDate: '00:00:00.000' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1397,7 +1398,7 @@ describe(MetadataService.name, () => { it('should handle invalid rating value', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: 6 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1411,7 +1412,7 @@ describe(MetadataService.name, () => { it('should handle valid rating value', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: 5 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1425,7 +1426,7 @@ describe(MetadataService.name, () => { it('should handle 0 as unrated -> null', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: 0 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1439,7 +1440,7 @@ describe(MetadataService.name, () => { it('should handle valid negative rating value', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: -1 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1453,7 +1454,7 @@ describe(MetadataService.name, () => { it('should handle livePhotoCID not set', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1468,7 +1469,7 @@ describe(MetadataService.name, () => { it('should handle not finding a match', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ContentIdentifier: 'CID' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1490,7 +1491,7 @@ describe(MetadataService.name, () => { it('should link photo and video', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1518,7 +1519,7 @@ describe(MetadataService.name, () => { it('should notify clients on live photo link', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1533,7 +1534,7 @@ describe(MetadataService.name, () => { it('should search by libraryId', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, libraryId: 'library-id' }); const asset = AssetFactory.create({ libraryId: 'library-id' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1568,9 +1569,14 @@ describe(MetadataService.name, () => { expected: { make: '1', model: '2' }, }, { exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } }, + { exif: { DeviceManufacturer: '1', DeviceModelName: '2' }, expected: { make: '1', model: '2' } }, + { + exif: { Make: '1', Model: '2', DeviceManufacturer: '3', DeviceModelName: '4' }, + expected: { make: '1', model: '2' }, + }, ])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(exif); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1595,7 +1601,7 @@ describe(MetadataService.name, () => { { exif: { LensID: '' }, expected: null }, ])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(exif); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1609,7 +1615,7 @@ describe(MetadataService.name, () => { it('should properly set width/height for normal images', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1623,7 +1629,7 @@ describe(MetadataService.name, () => { it('should properly swap asset width/height for rotated images', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1637,7 +1643,7 @@ describe(MetadataService.name, () => { it('should not overwrite existing width/height if they already exist', async () => { const asset = AssetFactory.create({ width: 1920, height: 1080 }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1754,17 +1760,20 @@ describe(MetadataService.name, () => { it('should skip jobs with no metadata', async () => { mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]); - const asset = factory.jobAssets.sidecarWrite(); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should write tags', async () => { - const asset = factory.jobAssets.sidecarWrite(); const description = 'this is a description'; const gps = 12; const date = '2023-11-21T22:56:12.196-06:00'; + const asset = AssetFactory.from() + .file({ type: AssetFileType.Sidecar }) + .exif({ description, dateTimeOriginal: new Date(date), latitude: gps, longitude: gps }) + .build(); mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([ 'description', @@ -1773,7 +1782,7 @@ describe(MetadataService.name, () => { 'dateTimeOriginal', 'timeZone', ]); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect( sut.handleSidecarWrite({ id: asset.id, @@ -1796,22 +1805,22 @@ describe(MetadataService.name, () => { }); it('should write rating', async () => { - const asset = factory.jobAssets.sidecarWrite(); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); asset.exifInfo.rating = 4; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 }); expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']); }); it('should write null rating as 0', async () => { - const asset = factory.jobAssets.sidecarWrite(); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); asset.exifInfo.rating = null; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 }); expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index f22d4682fa..d2467ae6d9 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,7 +8,7 @@ import { constants } from 'node:fs/promises'; import { join, parse } from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { Asset, AssetFace, AssetFile } from 'src/database'; +import { Asset, AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetFileType, @@ -289,8 +289,10 @@ export class MetadataService extends BaseService { colorspace: exifTags.ColorSpace === undefined ? null : String(exifTags.ColorSpace), // camera - make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, - model: exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? null, + make: + exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? (exifTags.DeviceManufacturer || null), + model: + exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? (exifTags.DeviceModelName || null), fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, @@ -447,8 +449,7 @@ export class MetadataService extends BaseService { const { description, dateTimeOriginal, latitude, longitude, rating, tags, timeZone } = _.pick( { description: asset.exifInfo.description, - // the kysely type is wrong here; fixed in 0.28.3 - dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null, + dateTimeOriginal: asset.exifInfo.dateTimeOriginal, latitude: asset.exifInfo.latitude, longitude: asset.exifInfo.longitude, rating: asset.exifInfo.rating ?? 0, @@ -829,7 +830,7 @@ export class MetadataService extends BaseService { } private async applyTaggedFaces( - asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string }, + asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string }, tags: ImmichTags, ) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index ee4b4ec05f..c7bea2b440 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -10,6 +10,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { UserFactory } from 'test/factories/user.factory'; import { notificationStub } from 'test/fixtures/notification.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { getForAlbum } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -269,14 +270,14 @@ describe(NotificationService.name, () => { }); it('should skip if recipient could not be found', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should skip if the recipient has email notifications disabled', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -292,7 +293,7 @@ describe(NotificationService.name, () => { }); it('should skip if the recipient has email notifications for album invite disabled', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -308,7 +309,7 @@ describe(NotificationService.name, () => { }); it('should send invite email', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -331,7 +332,7 @@ describe(NotificationService.name, () => { it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => { const album = AlbumFactory.create({ albumThumbnailAssetId: newUuid() }); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -363,7 +364,7 @@ describe(NotificationService.name, () => { it('should send invite email with album thumbnail as jpeg', async () => { const assetFile = AssetFileFactory.create({ type: AssetFileType.Thumbnail }); const album = AlbumFactory.create({ albumThumbnailAssetId: assetFile.assetId }); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -394,8 +395,10 @@ describe(NotificationService.name, () => { it('should send invite email with album thumbnail and arbitrary extension', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); - const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).asset(asset).build(); - mocks.album.getById.mockResolvedValue(album); + const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }) + .asset(asset, (builder) => builder.exif()) + .build(); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -432,7 +435,7 @@ describe(NotificationService.name, () => { }); it('should skip if owner could not be found', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create({ ownerId: 'non-existent' })); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create({ ownerId: 'non-existent' }))); await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped); expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); @@ -440,7 +443,7 @@ describe(NotificationService.name, () => { it('should skip recipient that could not be looked up', async () => { const album = AlbumFactory.from().albumUser({ userId: 'non-existent' }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValueOnce(album.owner); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -459,7 +462,7 @@ describe(NotificationService.name, () => { }) .build(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -478,7 +481,7 @@ describe(NotificationService.name, () => { }) .build(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -492,7 +495,7 @@ describe(NotificationService.name, () => { it('should send email', async () => { const user = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index db057a453a..029462a865 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerService } from 'src/services/partner.service'; -import { factory } from 'test/small.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; +import { UserFactory } from 'test/factories/user.factory'; +import { getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { @@ -18,26 +21,26 @@ describe(PartnerService.name, () => { describe('search', () => { it("should return a list of partners with whom I've shared my library", async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); + mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); await expect(sut.search(auth, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined(); expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id); }); it('should return a list of partners who have shared their libraries with me', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); + mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id); }); @@ -45,13 +48,13 @@ describe(PartnerService.name, () => { describe('create', () => { it('should create a new partner', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(void 0); - mocks.partner.create.mockResolvedValue(partner); + mocks.partner.create.mockResolvedValue(getForPartner(partner)); await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined(); @@ -62,12 +65,12 @@ describe(PartnerService.name, () => { }); it('should throw an error when the partner already exists', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.get.mockResolvedValue(partner); + mocks.partner.get.mockResolvedValue(getForPartner(partner)); await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException); @@ -77,12 +80,12 @@ describe(PartnerService.name, () => { describe('remove', () => { it('should remove a partner', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.get.mockResolvedValue(partner); + mocks.partner.get.mockResolvedValue(getForPartner(partner)); await sut.remove(auth, user2.id); @@ -90,8 +93,8 @@ describe(PartnerService.name, () => { }); it('should throw an error when the partner does not exist', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); mocks.partner.get.mockResolvedValue(void 0); @@ -103,20 +106,20 @@ describe(PartnerService.name, () => { describe('update', () => { it('should require access', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException); }); it('should update partner', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id])); - mocks.partner.update.mockResolvedValue(partner); + mocks.partner.update.mockResolvedValue(getForPartner(partner)); await expect(sut.update(auth, user2.id, { inTimeline: true })).resolves.toBeDefined(); expect(mocks.partner.update).toHaveBeenCalledWith( diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 628efa9d49..cc950edb5b 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -49,9 +49,8 @@ export class PartnerService extends BaseService { private mapPartner(partner: Partner, direction: PartnerDirection): PartnerResponseDto { // this is opposite to return the non-me user of the "partner" - const user = mapUser( - direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy, - ) as PartnerResponseDto; + const sharedUser = direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy; + const user = mapUser(sharedUser); return { ...user, inTimeline: partner.inTimeline }; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index c22fd65a1a..5c262d892d 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -12,7 +12,7 @@ import { PersonFactory } from 'test/factories/person.factory'; import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers'; +import { getAsDetectedFace, getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob } from 'test/mappers'; import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -202,16 +202,16 @@ describe(PersonService.name, () => { mocks.person.update.mockResolvedValue(person); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({ + await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({ id: person.id, name: person.name, birthDate: '1976-06-30', thumbnailPath: person.thumbnailPath, isHidden: false, isFavorite: false, - updatedAt: expect.any(Date), + updatedAt: expect.any(String), }); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, 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])); @@ -319,7 +319,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.person.getById.mockResolvedValue(person); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFacesByIds.mockResolvedValue([face]); + mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.refreshFaces.mockResolvedValue(); @@ -353,15 +353,17 @@ describe(PersonService.name, () => { const face = AssetFaceFactory.create(); const asset = AssetFactory.from({ id: face.assetId }).exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.person.getFaces.mockResolvedValue([face]); + mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]); mocks.asset.getForFaces.mockResolvedValue({ edits: [], ...asset.exifInfo }); - await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([mapFaces(face, auth)]); + await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([ + mapFaces(getForAssetFace(face), auth), + ]); }); it('should reject if the user has not access to the asset', async () => { const face = AssetFaceFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); - mocks.person.getFaces.mockResolvedValue([face]); + mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]); await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf( BadRequestException, ); @@ -390,7 +392,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFaceById.mockResolvedValue(face); + 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({ @@ -400,7 +402,7 @@ describe(PersonService.name, () => { id: person.id, name: person.name, thumbnailPath: person.thumbnailPath, - updatedAt: expect.any(Date), + updatedAt: expect.any(String), }); expect(mocks.job.queue).not.toHaveBeenCalledWith(); @@ -412,7 +414,7 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - mocks.person.getFaceById.mockResolvedValue(face); + mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(person); await expect( @@ -735,18 +737,18 @@ describe(PersonService.name, () => { }); it('should skip when no resize path', async () => { - const asset = AssetFactory.create(); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + const asset = AssetFactory.from().exif().build(); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should handle no results', async () => { const start = Date.now(); - const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( asset.files[0].path, @@ -764,12 +766,12 @@ describe(PersonService.name, () => { }); it('should create a face with no person and queue recognition job', async () => { - const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); 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.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); @@ -788,9 +790,9 @@ describe(PersonService.name, () => { }); it('should delete an existing face not among the new detected faces', async () => { - const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); await sut.handleDetectFaces({ id: asset.id }); @@ -809,9 +811,9 @@ describe(PersonService.name, () => { boundingBoxY1: 200, boundingBoxY2: 300, }); - const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.crypto.randomUUID.mockReturnValue(face.id); mocks.person.refreshFaces.mockResolvedValue(); @@ -832,9 +834,9 @@ describe(PersonService.name, () => { it('should add embedding to matching metadata face', async () => { const face = AssetFaceFactory.create({ sourceType: SourceType.Exif }); - const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); @@ -848,9 +850,9 @@ describe(PersonService.name, () => { it('should not add embedding to non-matching metadata face', async () => { const assetId = newUuid(); const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif }); - const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.crypto.randomUUID.mockReturnValue(face.id); await sut.handleDetectFaces({ id: asset.id }); @@ -1237,7 +1239,7 @@ describe(PersonService.name, () => { const person = PersonFactory.create({ ownerId: user.id }); const face = AssetFaceFactory.from().person(person).build(); - expect(mapFaces(face, auth)).toEqual({ + expect(mapFaces(getForAssetFace(face), auth)).toEqual({ boundingBoxX1: 100, boundingBoxX2: 200, boundingBoxY1: 100, @@ -1251,11 +1253,13 @@ describe(PersonService.name, () => { }); it('should not map person if person is null', () => { - expect(mapFaces(AssetFaceFactory.create(), AuthFactory.create()).person).toBeNull(); + expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull(); }); it('should not map person if person does not match auth user id', () => { - expect(mapFaces(AssetFaceFactory.from().person().build(), AuthFactory.create()).person).toBeNull(); + 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 8a902590e3..fb04ace4f2 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -491,7 +491,7 @@ export class PersonService extends BaseService { embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: machineLearning.facialRecognition.minFaces, - minBirthDate: face.asset.fileCreatedAt ?? undefined, + minBirthDate: new Date(face.asset.fileCreatedAt), }); // `matches` also includes the face itself @@ -519,7 +519,7 @@ export class PersonService extends BaseService { maxDistance: machineLearning.facialRecognition.maxDistance, numResults: 1, hasPerson: true, - minBirthDate: face.asset.fileCreatedAt ?? undefined, + minBirthDate: new Date(face.asset.fileCreatedAt), }); if (matchWithPerson.length > 0) { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 62575d0f07..f1cbccb7ec 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -5,6 +5,7 @@ import { SearchService } from 'src/services/search.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; @@ -74,7 +75,9 @@ describe(SearchService.name, () => { items: [{ value: 'city', data: asset.id }], }); mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]); - const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }]; + const expectedResponse = [ + { fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] }, + ]; const result = await sut.getExploreData(auth); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 7eacd148ad..8f4409a508 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,7 +1,8 @@ import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { @@ -25,9 +26,9 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - const currentSession = factory.session(); - const otherSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const otherSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); @@ -42,8 +43,8 @@ describe('SessionService', () => { describe('logoutDevices', () => { it('should logout all devices', async () => { - const currentSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.invalidate.mockResolvedValue(); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 07f31db4da..684d15bf7c 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -1,12 +1,14 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; +import { mapSharedLink } from 'src/dtos/shared-link.dto'; import { SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; +import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; +import { getForSharedLink } from 'test/mappers'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -24,11 +26,13 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { - mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); - await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ - sharedLinkResponseStub.expired, - sharedLinkResponseStub.valid, - ]); + const [sharedLink1, sharedLink2] = [SharedLinkFactory.create(), SharedLinkFactory.create()]; + mocks.sharedLink.getAll.mockResolvedValue([getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)]); + await expect(sut.getAll(authStub.user1, {})).resolves.toEqual( + [getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)].map((link) => + mapSharedLink(link, { stripAssetMetadata: false }), + ), + ); expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); }); @@ -41,8 +45,11 @@ describe(SharedLinkService.name, () => { it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + await expect(sut.getMine(authDto, [])).resolves.toEqual( + mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: false }), + ); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); @@ -54,7 +61,13 @@ describe(SharedLinkService.name, () => { allowUpload: true, }, }); - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); + mocks.sharedLink.get.mockResolvedValue( + getForSharedLink( + SharedLinkFactory.from({ showExif: false }) + .asset({}, (builder) => builder.exif()) + .build(), + ), + ); const response = await sut.getMine(authDto, []); expect(response.assets[0]).toMatchObject({ hasMetadata: false }); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); @@ -68,7 +81,8 @@ describe(SharedLinkService.name, () => { }); it('should accept a valid shared link auth token', async () => { - mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); + const sharedLink = SharedLinkFactory.create({ password: '123' }); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); const secret = Buffer.from('auth-token-123'); mocks.crypto.hashSha256.mockReturnValue(secret); await expect(sut.getMine(authStub.adminSharedLink, [secret.toString('base64')])).resolves.toBeDefined(); @@ -90,9 +104,12 @@ describe(SharedLinkService.name, () => { }); it('should get a shared link by id', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); - expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + await expect(sut.get(authStub.user1, sharedLink.id)).resolves.toEqual( + mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: true }), + ); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id); }); }); @@ -123,8 +140,9 @@ describe(SharedLinkService.name, () => { it('should create an album shared link', async () => { const album = AlbumFactory.from().asset().build(); + const sharedLink = SharedLinkFactory.from().album(album).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); await sut.create(authStub.admin, { type: SharedLinkType.Album, albumId: album.id }); @@ -145,8 +163,11 @@ describe(SharedLinkService.name, () => { it('should create an individual shared link', async () => { const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from() + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); await sut.create(authStub.admin, { type: SharedLinkType.Individual, @@ -178,8 +199,11 @@ describe(SharedLinkService.name, () => { it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from({ allowDownload: false }) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); await sut.create(authStub.admin, { type: SharedLinkType.Individual, @@ -221,8 +245,9 @@ describe(SharedLinkService.name, () => { }); it('should update a shared link', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink)); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); @@ -247,19 +272,21 @@ describe(SharedLinkService.name, () => { }); it('should remove a key', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); mocks.sharedLink.remove.mockResolvedValue(); - await sut.remove(authStub.user1, sharedLinkStub.valid.id); + await sut.remove(authStub.user1, sharedLink.id); - expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id); + expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLink.id); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.from().album().build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, @@ -268,11 +295,13 @@ describe(SharedLinkService.name, () => { it('should add assets to a shared link', async () => { const asset = AssetFactory.create(); - const sharedLink = SharedLinkFactory.from().asset(asset).build(); + const sharedLink = SharedLinkFactory.from() + .asset(asset, (builder) => builder.exif()) + .build(); const newAsset = AssetFactory.create(); - mocks.sharedLink.get.mockResolvedValue(sharedLink); - mocks.sharedLink.create.mockResolvedValue(sharedLink); - mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink)); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([newAsset.id])); await expect( @@ -286,7 +315,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ - ...sharedLink, + ...getForSharedLink(sharedLink), slug: null, assetIds: [newAsset.id], }); @@ -295,19 +324,22 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.from().album().build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); - await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( + await expect(sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should remove assets from a shared link', async () => { const asset = AssetFactory.create(); - const sharedLink = SharedLinkFactory.from().asset(asset).build(); - mocks.sharedLink.get.mockResolvedValue(sharedLink); - mocks.sharedLink.create.mockResolvedValue(sharedLink); - mocks.sharedLink.update.mockResolvedValue(sharedLink); + const sharedLink = SharedLinkFactory.from() + .asset(asset, (builder) => builder.exif()) + .build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink)); mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]); await expect( @@ -338,11 +370,14 @@ describe(SharedLinkService.name, () => { }); it('should return metadata tags', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); + const sharedLink = SharedLinkFactory.from({ description: null }) + .asset({}, (builder) => builder.exif()) + .build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', - imageUrl: `https://my.immich.app/api/assets/${sharedLinkStub.individual.assets[0].id}/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, + imageUrl: `https://my.immich.app/api/assets/${sharedLink.assets[0].id}/thumbnail?key=${sharedLink.key.toString('base64url')}`, title: 'Public Share', }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index b942c32326..26b15031ee 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { + if (auth.sharedLink) { + this.logger.deprecate( + 'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication', + ); + } + const sharedLink = await this.findOrFail(auth.user.id, id); if (sharedLink.type !== SharedLinkType.Individual) { diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 93f84e28e1..d47d634f4f 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -4,6 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { StackFactory } from 'test/factories/stack.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForStack } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -22,9 +23,11 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { const auth = AuthFactory.create(); - const asset = AssetFactory.create(); - const stack = StackFactory.from().primaryAsset(asset).build(); - mocks.stack.search.mockResolvedValue([stack]); + const asset = AssetFactory.from().exif().build(); + const stack = StackFactory.from() + .primaryAsset(asset, (builder) => builder.exif()) + .build(); + mocks.stack.search.mockResolvedValue([getForStack(stack)]); await sut.search(auth, { primaryAssetId: asset.id }); expect(mocks.stack.search).toHaveBeenCalledWith({ @@ -49,11 +52,14 @@ describe(StackService.name, () => { it('should create a stack', async () => { const auth = AuthFactory.create(); - const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()]; + const stack = StackFactory.from() + .primaryAsset(primaryAsset, (builder) => builder.exif()) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id])); - mocks.stack.create.mockResolvedValue(stack); + mocks.stack.create.mockResolvedValue(getForStack(stack)); await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({ id: stack.id, @@ -88,11 +94,14 @@ describe(StackService.name, () => { it('should get stack', async () => { const auth = AuthFactory.create(); - const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()]; + const stack = StackFactory.from() + .primaryAsset(primaryAsset, (builder) => builder.exif()) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); - mocks.stack.getById.mockResolvedValue(stack); + mocks.stack.getById.mockResolvedValue(getForStack(stack)); await expect(sut.get(auth, stack.id)).resolves.toEqual({ id: stack.id, @@ -125,10 +134,13 @@ describe(StackService.name, () => { it('should fail if the provided primary asset id is not in the stack', async () => { const auth = AuthFactory.create(); - const stack = StackFactory.from().primaryAsset().asset().build(); + const stack = StackFactory.from() + .primaryAsset({}, (builder) => builder.exif()) + .asset({}, (builder) => builder.exif()) + .build(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); - mocks.stack.getById.mockResolvedValue(stack); + mocks.stack.getById.mockResolvedValue(getForStack(stack)); await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, @@ -141,12 +153,15 @@ describe(StackService.name, () => { it('should update stack', async () => { const auth = AuthFactory.create(); - const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()]; + const stack = StackFactory.from() + .primaryAsset(primaryAsset, (builder) => builder.exif()) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); - mocks.stack.getById.mockResolvedValue(stack); - mocks.stack.update.mockResolvedValue(stack); + mocks.stack.getById.mockResolvedValue(getForStack(stack)); + mocks.stack.update.mockResolvedValue(getForStack(stack)); await sut.update(auth, stack.id, { primaryAssetId: asset.id }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 57343bb622..9d7262246c 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -6,7 +6,7 @@ import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { UserFactory } from 'test/factories/user.factory'; import { userStub } from 'test/fixtures/user.stub'; -import { getForStorageTemplate } from 'test/mappers'; +import { getForAlbum, getForStorageTemplate } from 'test/mappers'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build(); @@ -170,7 +170,9 @@ describe(StorageTemplateService.name, () => { .exif() .build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); @@ -182,7 +184,7 @@ describe(StorageTemplateService.name, () => { mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset)); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); - mocks.album.getByAssetId.mockResolvedValue([album]); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -211,7 +213,9 @@ describe(StorageTemplateService.name, () => { it('should use handlebar if condition for album', async () => { const user = UserFactory.create(); const asset = AssetFactory.from().owner(user).exif().build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; @@ -219,7 +223,7 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(user); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); - mocks.album.getByAssetId.mockResolvedValueOnce([album]); + mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -259,7 +263,9 @@ describe(StorageTemplateService.name, () => { it('should handle album startDate', async () => { const user = UserFactory.create(); const asset = AssetFactory.from().owner(user).exif().build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}'; @@ -268,7 +274,7 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(user); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); - mocks.album.getByAssetId.mockResolvedValueOnce([album]); + mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValueOnce([ { startDate: asset.fileCreatedAt, @@ -764,7 +770,9 @@ describe(StorageTemplateService.name, () => { }) .exif() .build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); @@ -775,7 +783,7 @@ describe(StorageTemplateService.name, () => { mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); - mocks.album.getByAssetId.mockResolvedValue([album]); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -803,7 +811,9 @@ describe(StorageTemplateService.name, () => { it('should use still photo album info when migrating live photo motion video', async () => { const user = userStub.user1; - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}'; @@ -812,7 +822,7 @@ describe(StorageTemplateService.name, () => { mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); mocks.user.getList.mockResolvedValue([user]); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); - mocks.album.getByAssetId.mockResolvedValue([album]); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.move.create.mockResolvedValueOnce({ id: '123', diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 395ff86099..234e3ac223 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,7 +1,9 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SyncService } from 'src/services/sync.service'; import { AssetFactory } from 'test/factories/asset.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset, getForPartner } from 'test/mappers'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -26,10 +28,10 @@ describe(SyncService.name, () => { AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(), AssetFactory.from().owner(authStub.user1.user).build(), ]; - mocks.asset.getAllForUserFullSync.mockResolvedValue([asset1, asset2]); + mocks.asset.getAllForUserFullSync.mockResolvedValue([getForAsset(asset1), getForAsset(asset2)]); await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ - mapAsset(asset1, mapAssetOpts), - mapAsset(asset2, mapAssetOpts), + mapAsset(getForAsset(asset1), mapAssetOpts), + mapAsset(getForAsset(asset2), mapAssetOpts), ]); expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ ownerId: authStub.user1.user.id, @@ -41,10 +43,10 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - const partner = factory.partner(); + const partner = PartnerFactory.create(); const auth = factory.auth({ user: { id: partner.sharedWithId } }); - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [auth.user.id] }), @@ -66,7 +68,9 @@ describe(SyncService.name, () => { it('should return a response requiring a full sync when there are too many changes', async () => { const asset = AssetFactory.create(); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue(Array.from({ length: 10_000 }).fill(asset)); + mocks.asset.getChangedDeltaSync.mockResolvedValue( + Array.from>({ length: 10_000 }).fill(getForAsset(asset)), + ); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); @@ -78,13 +82,13 @@ describe(SyncService.name, () => { const asset = AssetFactory.create({ ownerId: authStub.user1.user.id }); const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true }); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]); + mocks.asset.getChangedDeltaSync.mockResolvedValue([getForAsset(asset)]); mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: false, - upserted: [mapAsset(asset, mapAssetOpts)], + upserted: [mapAsset(getForAsset(asset), mapAssetOpts)], deleted: [deletedAsset.id], }); expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index d8e13fcfbd..49aefaa870 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; import { JobName, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe } from 'vitest'; @@ -126,8 +127,8 @@ describe(UserAdminService.name, () => { }); it('should not allow deleting own account', async () => { - const user = factory.userAdmin({ isAdmin: false }); - const auth = factory.auth({ user }); + const user = UserFactory.create({ isAdmin: false }); + const auth = AuthFactory.create(user); mocks.user.get.mockResolvedValue(user); await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index bd896ffc24..0dc83928fc 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -3,10 +3,11 @@ import { UserAdmin } from 'src/database'; import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const makeDeletedAt = (daysAgo: number) => { @@ -28,8 +29,8 @@ describe(UserService.name, () => { describe('getAll', () => { it('admin should get all users', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -39,8 +40,8 @@ describe(UserService.name, () => { }); it('non-admin should get all users when publicUsers enabled', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -105,7 +106,7 @@ describe(UserService.name, () => { it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); @@ -113,7 +114,7 @@ describe(UserService.name, () => { }); it('should delete the previous profile image', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const file = { path: '/profile/path' } as Express.Multer.File; const files = [user.profileImagePath]; @@ -149,7 +150,7 @@ describe(UserService.name, () => { }); it('should delete the profile image if user has one', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const files = [user.profileImagePath]; mocks.user.get.mockResolvedValue(user); @@ -178,7 +179,7 @@ describe(UserService.name, () => { }); it('should return the profile picture', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); await expect(sut.getProfileImage(user.id)).resolves.toEqual( @@ -205,7 +206,7 @@ describe(UserService.name, () => { }); it('should queue user ready for deletion', async () => { - const user = factory.user(); + const user = UserFactory.create(); mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]); await sut.handleUserDeleteCheck(); diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 7b26fb5eb3..a4bc51b0cc 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -2,6 +2,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { ViewService } from 'src/services/view.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ViewService.name, () => { @@ -37,7 +38,7 @@ describe(ViewService.name, () => { const mockAssets = [asset1, asset2]; - const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); + const mockAssetReponseDto = mockAssets.map((asset) => mapAsset(getForAsset(asset), { auth: authStub.admin })); mocks.view.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); diff --git a/server/src/types.ts b/server/src/types.ts index 8cf128f497..33174e187e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,3 +1,4 @@ +import { ShallowDehydrateObject } from 'kysely'; import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { Asset, AssetFile } from 'src/database'; @@ -548,3 +549,5 @@ export interface UserMetadata extends Record = T | ShallowDehydrateObject; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index d6ab825028..5420e60361 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -26,6 +26,8 @@ export const getAssetFiles = (files: AssetFile[]) => ({ editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }), + + encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }), }); export const addAssets = async ( diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 4a57cd1a98..03998d9462 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -4,23 +4,23 @@ import { DeduplicateJoinsPlugin, Expression, ExpressionBuilder, - ExpressionWrapper, Kysely, KyselyConfig, - Nullable, + NotNull, Selectable, SelectQueryBuilder, - Simplify, + ShallowDehydrateObject, sql, } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; -import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database'; +import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { VectorExtension } from 'src/types'; export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { @@ -70,28 +70,6 @@ export const removeUndefinedKeys = (update: T, template: unkno return update; }; -/** Modifies toJson return type to not set all properties as nullable */ -export function toJson>( - eb: ExpressionBuilder, - table: T, -) { - return eb.fn.toJson(table) as ExpressionWrapper< - DB, - TB, - Simplify< - T extends TB - ? Selectable extends Nullable - ? N | null - : Selectable - : T extends Expression - ? O extends Nullable - ? N | null - : O - : never - > - >; -} - export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export const isAssetChecksumConstraint = (error: unknown) => { @@ -106,19 +84,25 @@ export function withDefaultVisibility(qb: SelectQueryBuilder) export function withExif(qb: SelectQueryBuilder) { return qb .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo().as('exifInfo')); + .select((eb) => + eb.fn + .toJson(eb.table('asset_exif')) + .$castTo> | null>() + .as('exifInfo'), + ); } export function withExifInner(qb: SelectQueryBuilder) { return qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo().as('exifInfo')); + .select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')) + .$narrowType<{ exifInfo: NotNull }>(); } export function withSmartSearch(qb: SelectQueryBuilder) { return qb .leftJoin('smart_search', 'asset.id', 'smart_search.assetId') - .select((eb) => toJson(eb, 'smart_search').as('smartSearch')); + .select((eb) => jsonObjectFrom(eb.table('smart_search')).as('smartSearch')); } export function withFaces(eb: ExpressionBuilder, withHidden?: boolean, withDeletedFace?: boolean) { @@ -164,7 +148,7 @@ export function withFacesAndPeople( (join) => join.onTrue(), ) .selectAll('asset_face') - .select((eb) => eb.table('person').$castTo().as('person')) + .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)), @@ -371,7 +355,16 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) - .$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!)) + .$if(!!options.encodedVideoPath, (qb) => + qb + .innerJoin('asset_file', (join) => + join + .onRef('asset.id', '=', 'asset_file.assetId') + .on('asset_file.type', '=', AssetFileType.EncodedVideo) + .on('asset_file.isEdited', '=', false), + ) + .where('asset_file.path', '=', options.encodedVideoPath!), + ) .$if(!!options.originalPath, (qb) => qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), ) @@ -396,7 +389,15 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!)) .$if(options.isEncoded !== undefined, (qb) => - qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), + qb.where((eb) => { + const exists = eb.exists((eb) => + eb + .selectFrom('asset_file') + .whereRef('assetId', '=', 'asset.id') + .where('type', '=', AssetFileType.EncodedVideo), + ); + return options.isEncoded ? exists : eb.not(exists); + }), ) .$if(options.isMotion !== undefined, (qb) => qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), diff --git a/server/src/utils/date.ts b/server/src/utils/date.ts index 6cef48ecf8..092a0e6619 100644 --- a/server/src/utils/date.ts +++ b/server/src/utils/date.ts @@ -1,6 +1,10 @@ import { DateTime } from 'luxon'; -export const asDateString = (x: Date | string | null): string | null => { +export const asDateString = (x: T) => { + return x instanceof Date ? x.toISOString() : (x as Exclude); +}; + +export const asBirthDateString = (x: Date | string | null): string | null => { return x instanceof Date ? x.toISOString().split('T')[0] : x; }; diff --git a/server/test/factories/activity.factory.ts b/server/test/factories/activity.factory.ts new file mode 100644 index 0000000000..861b115158 --- /dev/null +++ b/server/test/factories/activity.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; +import { build } from 'test/factories/builder.factory'; +import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ActivityFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ActivityLike = {}) { + return ActivityFactory.from(dto).build(); + } + + static from(dto: ActivityLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ActivityFactory({ + albumId: newUuid(), + assetId: null, + comment: null, + createdAt: newDate(), + id: newUuid(), + isLiked: false, + userId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/api-key.factory.ts b/server/test/factories/api-key.factory.ts new file mode 100644 index 0000000000..d16b50ba57 --- /dev/null +++ b/server/test/factories/api-key.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { Permission } from 'src/enum'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; +import { build } from 'test/factories/builder.factory'; +import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ApiKeyFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ApiKeyLike = {}) { + return ApiKeyFactory.from(dto).build(); + } + + static from(dto: ApiKeyLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ApiKeyFactory({ + createdAt: newDate(), + id: newUuid(), + key: Buffer.from('api-key-buffer'), + name: 'API Key', + permissions: [Permission.All], + updatedAt: newDate(), + updateId: newUuidV7(), + userId, + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 4d54ba820b..ec596dc86e 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -55,7 +55,6 @@ export class AssetFactory { deviceId: '', duplicateId: null, duration: null, - encodedVideoPath: null, fileCreatedAt: newDate(), fileModifiedAt: newDate(), isExternal: false, diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts index 9c738aabac..fd38c42649 100644 --- a/server/test/factories/auth.factory.ts +++ b/server/test/factories/auth.factory.ts @@ -1,12 +1,16 @@ import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; import { build } from 'test/factories/builder.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; -import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; +import { newUuid } from 'test/small.factory'; export class AuthFactory { #user: UserFactory; #sharedLink?: SharedLinkFactory; + #apiKey?: ApiKeyFactory; + #session?: AuthDto['session']; private constructor(user: UserFactory) { this.#user = user; @@ -20,8 +24,8 @@ export class AuthFactory { return new AuthFactory(UserFactory.from(dto)); } - apiKey() { - // TODO + apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder) { + this.#apiKey = build(ApiKeyFactory.from(dto), builder); return this; } @@ -30,6 +34,11 @@ export class AuthFactory { return this; } + session(dto: Partial = {}) { + this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto }; + return this; + } + build(): AuthDto { const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); @@ -43,6 +52,8 @@ export class AuthFactory { quotaSizeInBytes, }, sharedLink: this.#sharedLink?.build(), + apiKey: this.#apiKey?.build(), + session: this.#session, }; } } diff --git a/server/test/factories/memory.factory.ts b/server/test/factories/memory.factory.ts new file mode 100644 index 0000000000..bda1d15c25 --- /dev/null +++ b/server/test/factories/memory.factory.ts @@ -0,0 +1,45 @@ +import { Selectable } from 'kysely'; +import { MemoryType } from 'src/enum'; +import { MemoryTable } from 'src/schema/tables/memory.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetLike, FactoryBuilder, MemoryLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class MemoryFactory { + #assets: AssetFactory[] = []; + + private constructor(private readonly value: Selectable) {} + + static create(dto: MemoryLike = {}) { + return MemoryFactory.from(dto).build(); + } + + static from(dto: MemoryLike = {}) { + return new MemoryFactory({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUuidV7(), + deletedAt: null, + ownerId: newUuid(), + type: MemoryType.OnThisDay, + data: { year: 2024 }, + isSaved: false, + memoryAt: newDate(), + seenAt: null, + showAt: newDate(), + hideAt: newDate(), + ...dto, + }); + } + + asset(asset: AssetLike, builder?: FactoryBuilder) { + this.#assets.push(build(AssetFactory.from(asset), builder)); + return this; + } + + build() { + return { ...this.value, assets: this.#assets.map((asset) => asset.build()) }; + } +} diff --git a/server/test/factories/partner.factory.ts b/server/test/factories/partner.factory.ts new file mode 100644 index 0000000000..f631db1eb5 --- /dev/null +++ b/server/test/factories/partner.factory.ts @@ -0,0 +1,50 @@ +import { Selectable } from 'kysely'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { build } from 'test/factories/builder.factory'; +import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PartnerFactory { + #sharedWith!: UserFactory; + #sharedBy!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: PartnerLike = {}) { + return PartnerFactory.from(dto).build(); + } + + static from(dto: PartnerLike = {}) { + const sharedById = dto.sharedById ?? newUuid(); + const sharedWithId = dto.sharedWithId ?? newUuid(); + return new PartnerFactory({ + createdAt: newDate(), + createId: newUuidV7(), + inTimeline: true, + sharedById, + sharedWithId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }) + .sharedBy({ id: sharedById }) + .sharedWith({ id: sharedWithId }); + } + + sharedWith(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedWith = build(UserFactory.from(dto), builder); + this.value.sharedWithId = this.#sharedWith.build().id; + return this; + } + + sharedBy(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedBy = build(UserFactory.from(dto), builder); + this.value.sharedById = this.#sharedBy.build().id; + return this; + } + + build() { + return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() }; + } +} diff --git a/server/test/factories/session.factory.ts b/server/test/factories/session.factory.ts new file mode 100644 index 0000000000..8d4cb28727 --- /dev/null +++ b/server/test/factories/session.factory.ts @@ -0,0 +1,35 @@ +import { Selectable } from 'kysely'; +import { SessionTable } from 'src/schema/tables/session.table'; +import { SessionLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class SessionFactory { + private constructor(private value: Selectable) {} + + static create(dto: SessionLike = {}) { + return SessionFactory.from(dto).build(); + } + + static from(dto: SessionLike = {}) { + return new SessionFactory({ + appVersion: null, + createdAt: newDate(), + deviceOS: 'android', + deviceType: 'mobile', + expiresAt: null, + id: newUuid(), + isPendingSyncReset: false, + parentId: null, + pinExpiresAt: null, + token: Buffer.from('abc123'), + updateId: newUuidV7(), + updatedAt: newDate(), + userId: newUuid(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/shared-link.factory.ts b/server/test/factories/shared-link.factory.ts index 5ac5f1756b..a37283df75 100644 --- a/server/test/factories/shared-link.factory.ts +++ b/server/test/factories/shared-link.factory.ts @@ -51,12 +51,14 @@ export class SharedLinkFactory { album(dto: AlbumLike = {}, builder?: FactoryBuilder) { this.#album = build(AlbumFactory.from(dto), builder); + this.value.type = SharedLinkType.Album; return this; } asset(dto: AssetLike = {}, builder?: FactoryBuilder) { const asset = build(AssetFactory.from(dto), builder); this.#assets.push(asset); + this.value.type = SharedLinkType.Individual; return this; } diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index c5a327a624..e2d9e4e1c3 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,12 +1,17 @@ import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; 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 { MemoryTable } from 'src/schema/tables/memory.table'; +import { PartnerTable } from 'src/schema/tables/partner.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'; import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -24,3 +29,8 @@ export type UserLike = Partial>; export type AssetFaceLike = Partial>; export type PersonLike = Partial>; export type StackLike = Partial>; +export type MemoryLike = Partial>; +export type PartnerLike = Partial>; +export type ActivityLike = Partial>; +export type ApiKeyLike = Partial>; +export type SessionLike = Partial>; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a42ff743bc..ac073c299d 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -1,7 +1,6 @@ -import { UserAdmin } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; -import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; +import { SharedLinkType } from 'src/enum'; import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -83,86 +82,7 @@ export const sharedLinkStub = { showExif: false, description: null, password: null, - assets: [ - { - id: 'id_1', - status: AssetStatus.Active, - owner: undefined as unknown as UserAdmin, - ownerId: 'user_id_1', - deviceAssetId: 'device_asset_id_1', - deviceId: 'device_id_1', - type: AssetType.Video, - originalPath: 'fake_path/jpeg', - checksum: Buffer.from('file hash', 'utf8'), - fileModifiedAt: today, - fileCreatedAt: today, - localDateTime: today, - createdAt: today, - updatedAt: today, - isFavorite: false, - isArchived: false, - isExternal: false, - isOffline: false, - files: [], - thumbhash: null, - encodedVideoPath: '', - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - originalFileName: 'asset_1.jpeg', - exifInfo: { - projectionType: null, - livePhotoCID: null, - assetId: 'id_1', - description: 'description', - exifImageWidth: 500, - exifImageHeight: 500, - fileSizeInByte: 100, - orientation: 'orientation', - dateTimeOriginal: today, - modifyDate: today, - timeZone: 'America/Los_Angeles', - latitude: 100, - longitude: 100, - city: 'city', - state: 'state', - country: 'country', - make: 'camera-make', - model: 'camera-model', - lensModel: 'fancy', - fNumber: 100, - focalLength: 100, - iso: 100, - exposureTime: '1/16', - fps: 100, - profileDescription: 'sRGB', - bitsPerSample: 8, - colorspace: 'sRGB', - autoStackId: null, - rating: 3, - updatedAt: today, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: 500, - height: 500, - tags: [], - }, - sharedLinks: [], - faces: [], - sidecarPath: null, - deletedAt: null, - duplicateId: null, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: 500, - height: 500, - isEdited: false, - }, - ], + assets: [], albumId: null, album: null, slug: null, diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index ca66af7b94..8382dec142 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -55,15 +55,15 @@ export const tagStub = { export const tagResponseStub = { tag1: Object.freeze({ id: 'tag-1', - createdAt: new Date('2021-01-01T00:00:00Z'), - updatedAt: new Date('2021-01-01T00:00:00Z'), + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', name: 'Tag1', value: 'Tag1', }), color1: Object.freeze({ id: 'tag-1', - createdAt: new Date('2021-01-01T00:00:00Z'), - updatedAt: new Date('2021-01-01T00:00:00Z'), + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', color: '#000000', name: 'Tag1', value: 'Tag1', diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 7ccd61a48c..7f324663be 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,7 +1,15 @@ -import { Selectable } from 'kysely'; +import { Selectable, ShallowDehydrateObject } from 'kysely'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; +import { ActivityTable } from 'src/schema/tables/activity.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; +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'; export const getForStorageTemplate = (asset: ReturnType) => { return { @@ -47,6 +55,170 @@ export const getForFacialRecognitionJob = ( asset: Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null, ) => ({ ...face, - asset, + asset: asset + ? { ownerId: asset.ownerId, visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt.toISOString() } + : null, faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' }, }); + +export const getDehydrated = >(entity: T) => { + const copiedEntity = structuredClone(entity); + for (const [key, value] of Object.entries(copiedEntity)) { + if (value instanceof Date) { + Object.assign(copiedEntity, { [key]: value.toISOString() }); + continue; + } + } + + return copiedEntity as ShallowDehydrateObject; +}; + +export const getForAlbum = (album: ReturnType) => ({ + ...album, + assets: album.assets.map((asset) => + getDehydrated({ ...getForAsset(asset), exifInfo: getDehydrated(asset.exifInfo) }), + ), + albumUsers: album.albumUsers.map((albumUser) => ({ + ...albumUser, + createdAt: albumUser.createdAt.toISOString(), + user: getDehydrated(albumUser.user), + })), + owner: getDehydrated(album.owner), + sharedLinks: album.sharedLinks.map((sharedLink) => getDehydrated(sharedLink)), +}); + +export const getForActivity = (activity: Selectable & { user: ReturnType }) => ({ + ...activity, + user: getDehydrated(activity.user), +}); + +export const getForAsset = (asset: ReturnType) => { + return { + ...asset, + faces: asset.faces.map((face) => ({ + ...getDehydrated(face), + person: face.person ? getDehydrated(face.person) : null, + })), + owner: getDehydrated(asset.owner), + stack: asset.stack + ? { ...getDehydrated(asset.stack), assets: asset.stack.assets.map((asset) => getDehydrated(asset)) } + : null, + files: asset.files.map((file) => getDehydrated(file)), + exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null, + edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[], + }; +}; + +export const getForPartner = ( + partner: Selectable & Record<'sharedWith' | 'sharedBy', ReturnType>, +) => ({ + ...partner, + sharedBy: getDehydrated(partner.sharedBy), + sharedWith: getDehydrated(partner.sharedWith), +}); + +export const getForMemory = (memory: ReturnType) => ({ + ...memory, + assets: memory.assets.map((asset) => getDehydrated(asset)), +}); + +export const getForMetadataExtraction = (asset: ReturnType) => ({ + id: asset.id, + checksum: asset.checksum, + deviceAssetId: asset.deviceAssetId, + deviceId: asset.deviceId, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + isExternal: asset.isExternal, + visibility: asset.visibility, + libraryId: asset.libraryId, + livePhotoVideoId: asset.livePhotoVideoId, + localDateTime: asset.localDateTime, + originalFileName: asset.originalFileName, + originalPath: asset.originalPath, + ownerId: asset.ownerId, + type: asset.type, + width: asset.width, + height: asset.height, + faces: asset.faces.map((face) => getDehydrated(face)), + files: asset.files.map((file) => getDehydrated(file)), +}); + +export const getForGenerateThumbnail = (asset: ReturnType) => ({ + id: asset.id, + visibility: asset.visibility, + originalFileName: asset.originalFileName, + originalPath: asset.originalPath, + ownerId: asset.ownerId, + thumbhash: asset.thumbhash, + type: asset.type, + files: asset.files.map((file) => getDehydrated(file)), + exifInfo: getDehydrated(asset.exifInfo), + edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[], +}); + +export const getForAssetFace = (face: ReturnType) => ({ + ...face, + person: face.person ? getDehydrated(face.person) : null, +}); + +export const getForDetectedFaces = (asset: ReturnType) => ({ + id: asset.id, + visibility: asset.visibility, + exifInfo: getDehydrated(asset.exifInfo), + faces: asset.faces.map((face) => getDehydrated(face)), + files: asset.files.map((file) => getDehydrated(file)), +}); + +export const getForSidecarWrite = (asset: ReturnType) => ({ + id: asset.id, + originalPath: asset.originalPath, + files: asset.files.map((file) => getDehydrated(file)), + exifInfo: getDehydrated(asset.exifInfo), +}); + +export const getForAssetDeletion = (asset: ReturnType) => ({ + id: asset.id, + visibility: asset.visibility, + libraryId: asset.libraryId, + ownerId: asset.ownerId, + livePhotoVideoId: asset.livePhotoVideoId, + originalPath: asset.originalPath, + isOffline: asset.isOffline, + exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null, + files: asset.files.map((file) => getDehydrated(file)), + stack: asset.stack + ? { + ...getDehydrated(asset.stack), + assets: asset.stack.assets.filter(({ id }) => id !== asset.stack?.primaryAssetId).map(({ id }) => ({ id })), + } + : null, +}); + +export const getForStack = (stack: ReturnType) => ({ + ...stack, + assets: stack.assets.map((asset) => ({ + ...getDehydrated(asset), + exifInfo: getDehydrated(asset.exifInfo), + })), +}); + +export const getForDuplicate = (asset: ReturnType) => ({ + ...getDehydrated(asset), + exifInfo: getDehydrated(asset.exifInfo), +}); + +export const getForSharedLink = (sharedLink: ReturnType) => ({ + ...sharedLink, + assets: sharedLink.assets.map((asset) => ({ + ...getDehydrated({ ...getForAsset(asset) }), + exifInfo: getDehydrated(asset.exifInfo), + })), + album: sharedLink.album + ? { + ...getDehydrated(sharedLink.album), + owner: getDehydrated(sharedLink.album.owner), + assets: sharedLink.album.assets.map((asset) => getDehydrated(asset)), + } + : null, +}); diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 97f503e9ed..896489672e 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -1,9 +1,11 @@ import { Kysely } from 'kysely'; +import { AssetOrder, AssetVisibility } from 'src/enum'; import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DB } from 'src/schema'; import { BaseService } from 'src/services/base.service'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -22,6 +24,61 @@ beforeAll(async () => { }); describe(AssetRepository.name, () => { + describe('getTimeBucket', () => { + it('should order assets by local day first and fileCreatedAt within each day', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const [{ asset: previousLocalDayAsset }, { asset: nextLocalDayEarlierAsset }, { asset: nextLocalDayLaterAsset }] = + await Promise.all([ + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'), + localDateTime: new Date('2026-03-08T22:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:30:00.000Z'), + localDateTime: new Date('2026-03-09T01:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:45:00.000Z'), + localDateTime: new Date('2026-03-09T01:45:00.000Z'), + }), + ]); + + await Promise.all([ + ctx.newExif({ assetId: previousLocalDayAsset.id, timeZone: 'UTC-2' }), + ctx.newExif({ assetId: nextLocalDayEarlierAsset.id, timeZone: 'UTC+2' }), + ctx.newExif({ assetId: nextLocalDayLaterAsset.id, timeZone: 'UTC+2' }), + ]); + + const descendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Desc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(descendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [nextLocalDayLaterAsset.id, nextLocalDayEarlierAsset.id, previousLocalDayAsset.id], + }), + ); + + const ascendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Asc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(ascendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [previousLocalDayAsset.id, nextLocalDayEarlierAsset.id, nextLocalDayLaterAsset.id], + }), + ); + }); + }); + describe('upsertExif', () => { it('should append to locked columns', async () => { const { ctx, sut } = setup(); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 06a5798405..57098e01ee 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,40 +1,7 @@ -import { - Activity, - Album, - ApiKey, - AssetFace, - AssetFile, - AuthApiKey, - AuthSharedLink, - AuthUser, - Exif, - Library, - Memory, - Partner, - Person, - Session, - Stack, - Tag, - User, - UserAdmin, -} from 'src/database'; -import { MapAsset } from 'src/dtos/asset-response.dto'; +import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; -import { - AssetFileType, - AssetOrder, - AssetStatus, - AssetType, - AssetVisibility, - MemoryType, - Permission, - SourceType, - UserMetadataKey, - UserStatus, -} from 'src/enum'; -import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types'; +import { AssetFileType, Permission, UserStatus } from 'src/enum'; import { v4, v7 } from 'uuid'; export const newUuid = () => v4(); @@ -123,41 +90,6 @@ const authUserFactory = (authUser: Partial = {}) => { return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; }; -const partnerFactory = (partner: Partial = {}) => { - const sharedBy = userFactory(partner.sharedBy || {}); - const sharedWith = userFactory(partner.sharedWith || {}); - - return { - sharedById: sharedBy.id, - sharedBy, - sharedWithId: sharedWith.id, - sharedWith, - createId: newUuidV7(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - inTimeline: true, - ...partner, - }; -}; - -const sessionFactory = (session: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - deviceOS: 'android', - deviceType: 'mobile', - token: Buffer.from('abc123'), - parentId: null, - expiresAt: null, - userId: newUuid(), - pinExpiresAt: newDate(), - isPendingSyncReset: false, - appVersion: session.appVersion ?? null, - ...session, -}); - const queueStatisticsFactory = (dto?: Partial) => ({ active: 0, completed: 0, @@ -168,35 +100,6 @@ const queueStatisticsFactory = (dto?: Partial) => ({ ...dto, }); -const stackFactory = ({ owner, assets, ...stack }: DeepPartial = {}): Stack => { - const ownerId = newUuid(); - - return { - id: newUuid(), - primaryAssetId: assets?.[0].id ?? newUuid(), - ownerId, - owner: userFactory(owner ?? { id: ownerId }), - assets: assets?.map((asset) => assetFactory(asset)) ?? [], - ...stack, - }; -}; - -const userFactory = (user: Partial = {}) => ({ - id: newUuid(), - name: 'Test User', - email: 'test@immich.cloud', - avatarColor: null, - profileImagePath: '', - profileChangedAt: newDate(), - metadata: [ - { - key: UserMetadataKey.Onboarding, - value: 'true', - }, - ] as UserMetadataItem[], - ...user, -}); - const userAdminFactory = (user: Partial = {}) => { const { id = newUuid(), @@ -238,72 +141,6 @@ const userAdminFactory = (user: Partial = {}) => { }; }; -const assetFactory = ( - asset: Omit, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {}, -) => { - return { - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - deletedAt: null, - updateId: newUuidV7(), - status: AssetStatus.Active, - checksum: newSha1(), - deviceAssetId: '', - deviceId: '', - duplicateId: null, - duration: null, - encodedVideoPath: null, - fileCreatedAt: newDate(), - fileModifiedAt: newDate(), - isExternal: false, - isFavorite: false, - isOffline: false, - libraryId: null, - livePhotoVideoId: null, - localDateTime: newDate(), - originalFileName: 'IMG_123.jpg', - originalPath: `/data/12/34/IMG_123.jpg`, - ownerId: newUuid(), - stackId: null, - thumbhash: null, - type: AssetType.Image, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - isEdited: false, - ...asset, - }; -}; - -const activityFactory = (activity: Partial = {}) => { - const userId = activity.userId || newUuid(); - return { - id: newUuid(), - comment: null, - isLiked: false, - userId, - user: userFactory({ id: userId }), - assetId: newUuid(), - albumId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - ...activity, - }; -}; - -const apiKeyFactory = (apiKey: Partial = {}) => ({ - id: newUuid(), - userId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - name: 'Api Key', - permissions: [Permission.All], - ...apiKey, -}); - const libraryFactory = (library: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), @@ -319,24 +156,6 @@ const libraryFactory = (library: Partial = {}) => ({ ...library, }); -const memoryFactory = (memory: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - deletedAt: null, - ownerId: newUuid(), - type: MemoryType.OnThisDay, - data: { year: 2024 } as OnThisDayData, - isSaved: false, - memoryAt: newDate(), - seenAt: null, - showAt: newDate(), - hideAt: newDate(), - assets: [], - ...memory, -}); - const versionHistoryFactory = () => ({ id: newUuid(), createdAt: newDate(), @@ -403,156 +222,15 @@ const assetOcrFactory = ( ...ocr, }); -const assetFileFactory = (file: Partial = {}) => ({ - id: newUuid(), - type: AssetFileType.Preview, - path: '/uploads/user-id/thumbs/path.jpg', - isEdited: false, - isProgressive: false, - ...file, -}); - -const exifFactory = (exif: Partial = {}) => ({ - assetId: newUuid(), - autoStackId: null, - bitsPerSample: null, - city: 'Austin', - colorspace: null, - country: 'United States of America', - dateTimeOriginal: newDate(), - description: '', - exifImageHeight: 420, - exifImageWidth: 42, - exposureTime: null, - fileSizeInByte: 69, - fNumber: 1.7, - focalLength: 4.38, - fps: null, - iso: 947, - latitude: 30.267_334_570_570_195, - longitude: -97.789_833_534_282_07, - lensModel: null, - livePhotoCID: null, - make: 'Google', - model: 'Pixel 7', - modifyDate: newDate(), - orientation: '1', - profileDescription: null, - projectionType: null, - rating: 4, - state: 'Texas', - tags: ['parent/child'], - timeZone: 'UTC-6', - ...exif, -}); - -const tagFactory = (tag: Partial): Tag => ({ - id: newUuid(), - color: null, - createdAt: newDate(), - parentId: null, - updatedAt: newDate(), - value: `tag-${newUuid()}`, - ...tag, -}); - -const faceFactory = ({ person, ...face }: DeepPartial = {}): AssetFace => ({ - assetId: newUuid(), - boundingBoxX1: 1, - boundingBoxX2: 2, - boundingBoxY1: 1, - boundingBoxY2: 2, - deletedAt: null, - id: newUuid(), - imageHeight: 420, - imageWidth: 42, - isVisible: true, - personId: null, - sourceType: SourceType.MachineLearning, - updatedAt: newDate(), - updateId: newUuidV7(), - person: person === null ? null : personFactory(person), - ...face, -}); - -const assetEditFactory = (edit?: Partial): AssetEditActionItem => { - switch (edit?.action) { - case AssetEditAction.Crop: { - return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit }; - } - case AssetEditAction.Mirror: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit }; - } - case AssetEditAction.Rotate: { - return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit }; - } - default: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }; - } - } -}; - -const personFactory = (person?: Partial): Person => ({ - birthDate: newDate(), - color: null, - createdAt: newDate(), - faceAssetId: null, - id: newUuid(), - isFavorite: false, - isHidden: false, - name: 'person', - ownerId: newUuid(), - thumbnailPath: '/path/to/person/thumbnail.jpg', - updatedAt: newDate(), - updateId: newUuidV7(), - ...person, -}); - -const albumFactory = (album?: Partial>) => ({ - albumName: 'My Album', - albumThumbnailAssetId: null, - albumUsers: [], - assets: [], - createdAt: newDate(), - deletedAt: null, - description: 'Album description', - id: newUuid(), - isActivityEnabled: false, - order: AssetOrder.Desc, - ownerId: newUuid(), - sharedLinks: [], - updatedAt: newDate(), - updateId: newUuidV7(), - ...album, -}); - export const factory = { - activity: activityFactory, - apiKey: apiKeyFactory, - asset: assetFactory, - assetFile: assetFileFactory, assetOcr: assetOcrFactory, auth: authFactory, - authApiKey: authApiKeyFactory, - authUser: authUserFactory, library: libraryFactory, - memory: memoryFactory, - partner: partnerFactory, queueStatistics: queueStatisticsFactory, - session: sessionFactory, - stack: stackFactory, - user: userFactory, - userAdmin: userAdminFactory, versionHistory: versionHistoryFactory, jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, - exif: exifFactory, - face: faceFactory, - person: personFactory, - assetEdit: assetEditFactory, - tag: tagFactory, - album: albumFactory, uuid: newUuid, buffer: () => Buffer.from('this is a fake buffer'), date: newDate, diff --git a/web/package.json b/web/package.json index 5bec4e786b..9c63b2e5f5 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,8 @@ "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", "thumbhash": "^0.1.1", "transformation-matrix": "^3.1.0", "uplot": "^1.6.32" @@ -98,7 +100,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.53.5", + "svelte": "5.53.7", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", diff --git a/web/src/lib/actions/image-loader.svelte.ts b/web/src/lib/actions/image-loader.svelte.ts new file mode 100644 index 0000000000..49a53dac26 --- /dev/null +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -0,0 +1,25 @@ +import { cancelImageUrl } from '$lib/utils/sw-messaging'; + +export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) { + let destroyed = false; + + const handleLoad = () => !destroyed && onLoad(); + const handleError = () => !destroyed && onError(); + + const img = document.createElement('img'); + img.addEventListener('load', handleLoad); + img.addEventListener('error', handleError); + + onStart?.(); + img.src = src; + + return () => { + destroyed = true; + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleError); + cancelImageUrl(src); + img.remove(); + }; +} + +export type LoadImageFunction = typeof loadImage; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 6288daa380..35c3d3a106 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -2,23 +2,46 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { createZoomImageWheel } from '@zoom-image/core'; export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState }); + const zoomInstance = createZoomImageWheel(node, { + maxZoom: 10, + initialState: assetViewerManager.zoomState, + zoomTarget: null, + }); const unsubscribes = [ assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)), ]; - const stopIfDisabled = (event: Event) => { + const onInteractionStart = (event: Event) => { if (options?.disabled) { event.stopImmediatePropagation(); } + assetViewerManager.cancelZoomAnimation(); }; - node.addEventListener('wheel', stopIfDisabled, { capture: true }); - node.addEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.addEventListener('wheel', onInteractionStart, { capture: true }); + node.addEventListener('pointerdown', onInteractionStart, { capture: true }); + // Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart + // handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's + // handler which conflicts. Chrome does not fire synthetic dblclick on touch. + let lastPointerWasTouch = false; + const trackPointerType = (event: PointerEvent) => { + lastPointerWasTouch = event.pointerType === 'touch'; + }; + const suppressTouchDblClick = (event: MouseEvent) => { + if (lastPointerWasTouch) { + event.stopImmediatePropagation(); + } + }; + node.addEventListener('pointerdown', trackPointerType, { capture: true }); + node.addEventListener('dblclick', suppressTouchDblClick, { capture: true }); + + // Allow zoomed content to render outside the container bounds node.style.overflow = 'visible'; + // Prevent browser handling of touch gestures so zoom-image can manage them + node.style.touchAction = 'none'; return { update(newOptions?: { disabled?: boolean }) { options = newOptions; @@ -27,8 +50,10 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea for (const unsubscribe of unsubscribes) { unsubscribe(); } - node.removeEventListener('wheel', stopIfDisabled, { capture: true }); - node.removeEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.removeEventListener('wheel', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', trackPointerType, { capture: true }); + node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte new file mode 100644 index 0000000000..fad4d49d1b --- /dev/null +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -0,0 +1,229 @@ + + +
+ {@render backdrop?.()} + + +
+
+ {#if show.alphaBackground} + + {/if} + + {#if show.thumbhash} + {#if asset.thumbhash} + + + {:else if show.spinner} + + {/if} + {/if} + + {#if show.thumbnail} + + {/if} + + {#if show.brokenAsset} + + {/if} + + {#if show.preview} + + {/if} + + {#if show.original} + + {/if} +
+
+
diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte new file mode 100644 index 0000000000..5c3869d587 --- /dev/null +++ b/web/src/lib/components/AlphaBackground.svelte @@ -0,0 +1,12 @@ + + +
diff --git a/web/src/lib/components/DelayedLoadingSpinner.svelte b/web/src/lib/components/DelayedLoadingSpinner.svelte new file mode 100644 index 0000000000..d18d373566 --- /dev/null +++ b/web/src/lib/components/DelayedLoadingSpinner.svelte @@ -0,0 +1,20 @@ + + +
+ +
+ + diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte index 417af56192..7ad6dc3ab7 100644 --- a/web/src/lib/components/Image.svelte +++ b/web/src/lib/components/Image.svelte @@ -1,4 +1,5 @@ + +{#key adaptiveImageLoader} +
+ adaptiveImageLoader.onStart(quality)} + onLoad={() => adaptiveImageLoader.onLoad(quality)} + onError={() => adaptiveImageLoader.onError(quality)} + bind:ref + class="h-full w-full bg-transparent" + {alt} + {role} + draggable={false} + data-testid={quality} + /> + {@render overlays?.()} +
+{/key} diff --git a/web/src/lib/components/LoadingDots.svelte b/web/src/lib/components/LoadingDots.svelte new file mode 100644 index 0000000000..7e6692021f --- /dev/null +++ b/web/src/lib/components/LoadingDots.svelte @@ -0,0 +1,47 @@ + + +
+ {#each [0, 1, 2] as i (i)} + + {/each} +
+ + diff --git a/web/src/lib/components/QueueCard.svelte b/web/src/lib/components/QueueCard.svelte index b7cde7b8f1..448558ed9f 100644 --- a/web/src/lib/components/QueueCard.svelte +++ b/web/src/lib/components/QueueCard.svelte @@ -1,4 +1,5 @@ - -
+
{@render children?.()}
diff --git a/web/src/lib/components/QueueCardButton.svelte b/web/src/lib/components/QueueCardButton.svelte index f71d8a3e44..9964b8fd1a 100644 --- a/web/src/lib/components/QueueCardButton.svelte +++ b/web/src/lib/components/QueueCardButton.svelte @@ -4,6 +4,7 @@ - diff --git a/web/src/lib/components/QueueGraph.svelte b/web/src/lib/components/QueueGraph.svelte index f2a23216df..01327643a1 100644 --- a/web/src/lib/components/QueueGraph.svelte +++ b/web/src/lib/components/QueueGraph.svelte @@ -1,4 +1,5 @@ -
+
{#if data[0].length === 0} {/if} diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index aec1761998..25af7bf2c1 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -11,7 +11,7 @@ import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk'; - import { Button, modalManager, Text, toastManager } from '@immich/ui'; + import { Button, Link, modalManager, Text, toastManager } from '@immich/ui'; import { mdiRestart } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -75,14 +75,7 @@ {#snippet children({ message })} - - {message} - + {message} {/snippet} diff --git a/web/src/lib/components/admin-settings/BackupSettings.svelte b/web/src/lib/components/admin-settings/BackupSettings.svelte index fc374ddd6f..7fd22a2b6d 100644 --- a/web/src/lib/components/admin-settings/BackupSettings.svelte +++ b/web/src/lib/components/admin-settings/BackupSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -52,15 +53,10 @@

{#snippet children({ message })} - + {message}
-
+ {/snippet}

diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index e062b616b3..95aa9d74f2 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -18,7 +18,7 @@ VideoCodec, VideoContainer, } from '@immich/sdk'; - import { Icon } from '@immich/ui'; + import { Icon, Link } from '@immich/ui'; import { mdiHelpCircleOutline } from '@mdi/js'; import { isEqual, sortBy } from 'lodash-es'; import { t } from 'svelte-i18n'; @@ -38,17 +38,11 @@ {#snippet children({ tag, message })} {#if tag === 'h264-link'} - - {message} - + {message} {:else if tag === 'hevc-link'} - - {message} - + {message} {:else if tag === 'vp9-link'} - - {message} - + {message} {/if} {/snippet} diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index a91a5eb97a..52c2eb8d4f 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -8,6 +8,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -73,14 +74,11 @@

{#snippet children({ message })} - {message} - + {/snippet}

diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 692a5cfcf5..5888c82611 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -54,14 +55,7 @@

{#snippet children({ message })} - - {message} - + {message} {/snippet}

diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index 7018bc5d04..8ccb3f7781 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -12,7 +12,7 @@ import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { user } from '$lib/stores/user.store'; import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; - import { Heading, LoadingSpinner, Text } from '@immich/ui'; + import { Heading, Link, LoadingSpinner, Text } from '@immich/ui'; import handlebar from 'handlebars'; import * as luxon from 'luxon'; import { onDestroy } from 'svelte'; @@ -112,23 +112,11 @@ {#snippet children({ tag, message })} {#if tag === 'template-link'} - - {message} - + {message} {:else if tag === 'implications-link'} - + {message} - + {/if} {/snippet} diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index c6242c5fad..2af2be77f6 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -11,7 +11,7 @@ class?: string; } - let { album, preload = false, class: className = '' }: Props = $props(); + let { album, preload = false, class: className }: Props = $props(); let alt = $derived(album.albumName || $t('unnamed_album')); let thumbnailUrl = $derived( diff --git a/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts new file mode 100644 index 0000000000..38da1dc08d --- /dev/null +++ b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts @@ -0,0 +1,104 @@ +import { loadImage } from '$lib/actions/image-loader.svelte'; +import { getAssetUrls } from '$lib/utils'; +import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; +import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk'; + +type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; +}; + +export class PreloadManager { + private nextPreloader: AdaptiveImageLoader | undefined; + private previousPreloader: AdaptiveImageLoader | undefined; + + private startPreloader( + asset: AssetResponseDto | undefined, + sharedlink: SharedLinkResponseDto | undefined, + ): AdaptiveImageLoader | undefined { + if (!asset) { + return; + } + const urls = getAssetUrls(asset, sharedlink); + const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview'); + const qualityList: QualityList = [ + { + quality: 'thumbnail', + url: urls.thumbnail, + onAfterLoad: afterThumbnail, + onAfterError: afterThumbnail, + }, + { + quality: 'preview', + url: urls.preview, + onAfterError: (loader) => loader.trigger('original'), + }, + { quality: 'original', url: urls.original }, + ]; + const loader = new AdaptiveImageLoader(qualityList, undefined, loadImage); + loader.start(); + return loader; + } + + private destroyPreviousPreloader() { + this.previousPreloader?.destroy(); + this.previousPreloader = undefined; + } + + private destroyNextPreloader() { + this.nextPreloader?.destroy(); + this.nextPreloader = undefined; + } + + cancelBeforeNavigation(direction: 'previous' | 'next') { + switch (direction) { + case 'next': { + this.destroyPreviousPreloader(); + break; + } + case 'previous': { + this.destroyNextPreloader(); + break; + } + } + } + + updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + const movedForward = newCursor.current.id === oldCursor.nextAsset?.id; + const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id; + + if (!movedBackward) { + this.destroyPreviousPreloader(); + } + + if (!movedForward) { + this.destroyNextPreloader(); + } + + if (movedForward) { + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } else if (movedBackward) { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + } else { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } + } + + initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + if (cursor.nextAsset) { + this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink); + } + if (cursor.previousAsset) { + this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink); + } + } + + destroy() { + this.destroyNextPreloader(); + this.destroyPreviousPreloader(); + } +} + +export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index bb52c71260..3ccadf944f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -34,7 +34,9 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; - import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui'; + import LoadingDots from '$lib/components/LoadingDots.svelte'; + import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { mdiArrowLeft, mdiArrowRight, @@ -104,7 +106,16 @@
-
+
+ {#if assetViewerManager.isImageLoading} + + {#snippet child({ props })} +
+ +
+ {/snippet} +
+ {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts new file mode 100644 index 0000000000..a1f50da86a --- /dev/null +++ b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts @@ -0,0 +1,76 @@ +import { getAnimateMock } from '$lib/__mocks__/animate.mock'; +import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock'; +import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store'; +import { renderWithTooltips } from '$tests/helpers'; +import { updateAsset } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { preferencesFactory } from '@test-data/factories/preferences-factory'; +import { userAdminFactory } from '@test-data/factories/user-factory'; +import { fireEvent, waitFor } from '@testing-library/svelte'; +import AssetViewer from './asset-viewer.svelte'; + +vi.mock('$lib/managers/feature-flags-manager.svelte', () => ({ + featureFlagsManager: { + init: vi.fn(), + loadFeatureFlags: vi.fn(), + value: { smartSearch: true, trash: true }, + } as never, +})); + +vi.mock('$lib/stores/ocr.svelte', () => ({ + ocrManager: { + clear: vi.fn(), + getAssetOcr: vi.fn(), + hasOcrData: false, + showOverlay: false, + }, +})); + +vi.mock('@immich/sdk', async () => { + const sdk = await vi.importActual('@immich/sdk'); + return { + ...sdk, + updateAsset: vi.fn(), + }; +}); + +describe('AssetViewer', () => { + beforeAll(() => { + Element.prototype.animate = getAnimateMock(); + vi.stubGlobal('ResizeObserver', getResizeObserverMock()); + }); + + afterEach(() => { + resetSavedUser(); + vi.clearAllMocks(); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('updates the top bar favorite action after pressing favorite', async () => { + const ownerId = 'owner-id'; + const user = userAdminFactory.build({ id: ownerId }); + const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false }); + + userStore.set(user); + preferencesStore.set(preferencesFactory.build({ cast: { gCastEnabled: false } })); + vi.mocked(updateAsset).mockResolvedValue({ ...asset, isFavorite: true }); + + const { getByLabelText, queryByLabelText } = renderWithTooltips(AssetViewer, { + cursor: { current: asset }, + showNavigation: false, + }); + + expect(getByLabelText('to_favorite')).toBeInTheDocument(); + expect(queryByLabelText('unfavorite')).toBeNull(); + + await fireEvent.click(getByLabelText('to_favorite')); + + await waitFor(() => + expect(updateAsset).toHaveBeenCalledWith({ id: asset.id, updateAssetDto: { isFavorite: true } }), + ); + await waitFor(() => expect(getByLabelText('unfavorite')).toBeInTheDocument()); + }); +}); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 2a75ca4e83..3f7b048c8f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,15 +5,17 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { imageManager } from '$lib/managers/ImageManager.svelte'; import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -36,6 +38,7 @@ } from '@immich/sdk'; import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; @@ -92,20 +95,19 @@ stopProgress: stopSlideshowProgress, slideshowNavigation, slideshowState, - slideshowTransition, slideshowRepeat, } = slideshowStore; const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - const asset = $derived(cursor.current); + let previewStackedAsset: AssetResponseDto | undefined = $state(); + let stack: StackResponseDto | null = $state(null); + + const asset = $derived(previewStackedAsset ?? cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); let sharedLink = getSharedLink(); - let previewStackedAsset: AssetResponseDto | undefined = $state(); let fullscreenElement = $state(); - let unsubscribes: (() => void)[] = []; - let stack: StackResponseDto | null = $state(null); let playOriginalVideo = $state($alwaysLoadOriginalVideo); let slideshowStartAssetId = $state(); @@ -115,7 +117,7 @@ }; const refreshStack = async () => { - if (authManager.isSharedLink) { + if (authManager.isSharedLink || !withStacked) { return; } @@ -126,51 +128,56 @@ if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = null; } - - untrack(() => { - imageManager.preload(stack?.assets[1]); - }); }; const handleFavorite = async () => { - if (album && album.isActivityEnabled) { - try { - await activityManager.toggleLike(); - } catch (error) { - handleError(error, $t('errors.unable_to_change_favorite')); - } + if (!album || !album.isActivityEnabled) { + return; + } + + try { + await activityManager.toggleLike(); + } catch (error) { + handleError(error, $t('errors.unable_to_change_favorite')); + } + }; + + const onAssetUpdate = (updatedAsset: AssetResponseDto) => { + if (asset.id === updatedAsset.id) { + cursor = { ...cursor, current: updatedAsset }; } }; onMount(() => { syncAssetViewerOpenClass(true); - unsubscribes.push( - slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }), - slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }), - ); + const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }); + + const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }); + + return () => { + slideshowStateUnsubscribe(); + slideshowNavigationUnsubscribe(); + }; }); onDestroy(() => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - activityManager.reset(); assetViewerManager.closeEditor(); syncAssetViewerOpenClass(false); + preloadManager.destroy(); }); const closeViewer = () => { @@ -187,8 +194,7 @@ }; const tracker = new InvocationTracker(); - - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -197,16 +203,19 @@ } } - e?.stopPropagation(); - imageManager.cancel(asset); + preloadManager.cancelBeforeNavigation(order); + if (tracker.isActive()) { return; } void tracker.invoke(async () => { + const isShuffle = + $slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle; + let hasNext: boolean; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + if (isShuffle) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { const asset = await onRandom?.(); @@ -220,17 +229,22 @@ order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); } - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else if ($slideshowRepeat && slideshowStartAssetId) { - // Loop back to starting asset - await setAssetId(slideshowStartAssetId); - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; } + + if (hasNext) { + $restartSlideshowProgress = true; + return; + } + + if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + return; + } + + await handleStopSlideshow(); }, $t('error_while_navigating')); }; @@ -274,12 +288,14 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? asset : undefined; + const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { + previewStackedAsset = isMouseOver ? stackedAsset : undefined; }; + const handlePreAction = (action: Action) => { preAction?.(action); }; + const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.DELETE: @@ -352,17 +368,31 @@ await ocrManager.getAssetOcr(asset.id); } }; + $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset; untrack(() => handlePromiseError(refresh())); - imageManager.preload(cursor.nextAsset); - imageManager.preload(cursor.previousAsset); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor.current.id === lastCursor?.current.id) { + return; + } + if (lastCursor) { + preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink); + } + if (!lastCursor) { + preloadManager.initializePreloads(cursor, sharedLink); + } + lastCursor = cursor; }); const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; @@ -396,16 +426,35 @@ ocrManager.hasOcrData, ); - const { Tag } = $derived(getAssetActions($t, asset)); + const { Tag, TagPeople } = $derived(getAssetActions($t, asset)); const showDetailPanel = $derived( asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor, ); + + const onSwipe = (event: SwipeCustomEvent) => { + if (assetViewerManager.zoom > 1) { + return; + } + + if (ocrManager.showOverlay) { + return; + } + + if (event.detail.direction === 'left') { + navigateAsset('next'); + } + + if (event.detail.direction === 'right') { + navigateAsset('previous'); + } + }; - + + @@ -448,23 +497,15 @@
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
navigateAsset('previous')} />
{/if} -
- {#if viewerKind === 'StackPhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} - {sharedLink} - /> - {:else if viewerKind === 'StackVideoViewer'} +
+ {#if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'PhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - /> + {:else if viewerKind === 'VideoViewer'} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
navigateAsset('next')} />
diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts deleted file mode 100644 index d0d7f99ad3..0000000000 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock'; -import CropArea from '$lib/components/asset-viewer/editor/transform-tool/crop-area.svelte'; -import { transformManager } from '$lib/managers/edit/transform-manager.svelte'; -import { getAssetMediaUrl } from '$lib/utils'; -import { assetFactory } from '@test-data/factories/asset-factory'; -import { render } from '@testing-library/svelte'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; - -vi.mock('$lib/utils'); - -describe('CropArea', () => { - beforeAll(() => { - vi.stubGlobal('ResizeObserver', getResizeObserverMock()); - vi.mocked(getAssetMediaUrl).mockReturnValue('/mock-image.jpg'); - }); - - afterEach(() => { - transformManager.reset(); - }); - - it('clears cursor styles on reset', () => { - const asset = assetFactory.build(); - const { getByRole } = render(CropArea, { asset }); - const cropArea = getByRole('button', { name: 'Crop area' }); - - transformManager.region = { x: 100, y: 100, width: 200, height: 200 }; - transformManager.cropImageSize = { width: 1000, height: 1000 }; - transformManager.cropImageScale = 1; - transformManager.updateCursor(100, 150); - - expect(document.body.style.cursor).toBe('ew-resize'); - expect(cropArea.style.cursor).toBe('ew-resize'); - - transformManager.reset(); - - expect(document.body.style.cursor).toBe(''); - expect(cropArea.style.cursor).toBe(''); - }); - - it('sets cursor style at x: $x, y: $y to be $cursor', () => { - const data = [ - { x: 299, y: 84, cursor: '' }, - { x: 299, y: 85, cursor: 'nesw-resize' }, - { x: 299, y: 115, cursor: 'nesw-resize' }, - { x: 299, y: 116, cursor: 'ew-resize' }, - { x: 299, y: 284, cursor: 'ew-resize' }, - { x: 299, y: 285, cursor: 'nwse-resize' }, - { x: 299, y: 300, cursor: 'nwse-resize' }, - { x: 299, y: 301, cursor: '' }, - { x: 300, y: 84, cursor: '' }, - { x: 300, y: 85, cursor: 'nesw-resize' }, - { x: 300, y: 86, cursor: 'nesw-resize' }, - { x: 300, y: 114, cursor: 'nesw-resize' }, - { x: 300, y: 115, cursor: 'nesw-resize' }, - { x: 300, y: 116, cursor: 'ew-resize' }, - { x: 300, y: 284, cursor: 'ew-resize' }, - { x: 300, y: 285, cursor: 'nwse-resize' }, - { x: 300, y: 286, cursor: 'nwse-resize' }, - { x: 300, y: 300, cursor: 'nwse-resize' }, - { x: 300, y: 301, cursor: '' }, - { x: 301, y: 300, cursor: '' }, - { x: 301, y: 301, cursor: '' }, - ]; - - const element = document.createElement('div'); - - for (const { x, y, cursor } of data) { - const message = `x: ${x}, y: ${y} - ${cursor}`; - transformManager.reset(); - transformManager.region = { x: 100, y: 100, width: 200, height: 200 }; - transformManager.cropImageSize = { width: 600, height: 600 }; - transformManager.cropAreaEl = element; - transformManager.cropImageScale = 0.5; - transformManager.updateCursor(x, y); - expect(element.style.cursor, message).toBe(cursor); - expect(document.body.style.cursor, message).toBe(cursor); - } - }); -}); diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte index 7a84612fe8..011f30d445 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte @@ -1,9 +1,12 @@ -
- +
+ +
transformManager.handleMouseDownOn(e, ResizeBoundary.None)} + >
+ + {#each edges as edge (edge)} + {@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)} + + {/each} + + {#each corners as corner (corner)} + {@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)} + + {/each} +
+
diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 39088b23de..8b3d672bfe 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -3,7 +3,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils'; + import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; @@ -74,6 +74,7 @@ canvas.add(faceRect); canvas.setActiveObject(faceRect); + setDefaultFaceRectanglePosition(faceRect); }; onMount(async () => { @@ -81,16 +82,31 @@ await getPeople(); }); - $effect(() => { - const metrics = getContentMetrics(htmlElement); - - const imageBoundingBox = { - top: metrics.offsetY, - left: metrics.offsetX, - width: metrics.contentWidth, - height: metrics.contentHeight, + const imageContentMetrics = $derived.by(() => { + const natural = getNaturalSize(htmlElement); + const container = { width: containerWidth, height: containerHeight }; + const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container); + return { + contentWidth, + contentHeight, + offsetX: (containerWidth - contentWidth) / 2, + offsetY: (containerHeight - contentHeight) / 2, }; + }); + const setDefaultFaceRectanglePosition = (faceRect: Rect) => { + const { offsetX, offsetY } = imageContentMetrics; + + faceRect.set({ + top: offsetY + 200, + left: offsetX + 200, + }); + + faceRect.setCoords(); + positionFaceSelector(); + }; + + $effect(() => { if (!canvas) { return; } @@ -104,15 +120,21 @@ return; } - faceRect.set({ - top: imageBoundingBox.top + 200, - left: imageBoundingBox.left + 200, - }); - - faceRect.setCoords(); - positionFaceSelector(); + if (!isFaceRectIntersectingCanvas(faceRect, canvas)) { + setDefaultFaceRectanglePosition(faceRect); + } }); + const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => { + const faceBox = faceRect.getBoundingRect(); + return !( + 0 > faceBox.left + faceBox.width || + 0 > faceBox.top + faceBox.height || + canvas.width < faceBox.left || + canvas.height < faceBox.top + ); + }; + const cancel = () => { isFaceEditMode.value = false; }; @@ -214,13 +236,13 @@ } const { left, top, width, height } = faceRect.getBoundingRect(); - const metrics = getContentMetrics(htmlElement); + const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const natural = getNaturalSize(htmlElement); - const scaleX = natural.width / metrics.contentWidth; - const scaleY = natural.height / metrics.contentHeight; - const imageX = (left - metrics.offsetX) * scaleX; - const imageY = (top - metrics.offsetY) * scaleY; + const scaleX = natural.width / contentWidth; + const scaleY = natural.height / contentHeight; + const imageX = (left - offsetX) * scaleX; + const imageY = (top - offsetY) * scaleY; return { imageWidth: natural.width, diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index 6f6caad0fc..d5551b9cc5 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -1,6 +1,6 @@
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index c3874a81e9..4a6a02cb4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,66 +1,56 @@
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index b4772cc1c4..76956fbb26 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -1,4 +1,5 @@ -
- {#if sharedLink?.allowUpload || assets.length > 1} +{#if sharedLink?.allowUpload || assets.length > 1} +
+ +
+ +
{#if assetInteraction.selectionActive} {/if} -
- -
- {:else if assets.length === 1} - {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} - {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} - - {/await} +
+{:else if assets.length === 1} + {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} + {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + {/await} - {/if} -
+ {/await} +{/if} diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte index dbe32f2701..58ae508320 100644 --- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte @@ -2,8 +2,6 @@ import { clickOutside } from '$lib/actions/click-outside'; import { languageManager } from '$lib/managers/language-manager.svelte'; import type { Snippet } from 'svelte'; - import { quintOut } from 'svelte/easing'; - import { slide } from 'svelte/transition'; interface Props { isVisible?: boolean; @@ -14,6 +12,7 @@ ariaLabel?: string | undefined; ariaLabelledBy?: string | undefined; ariaActiveDescendant?: string | undefined; + menuScrollView?: HTMLDivElement | undefined; menuElement?: HTMLUListElement | undefined; onClose?: (() => void) | undefined; children?: Snippet; @@ -28,6 +27,7 @@ ariaLabel = undefined, ariaLabelledBy = undefined, ariaActiveDescendant = undefined, + menuScrollView = $bindable(), menuElement = $bindable(), onClose = undefined, children, @@ -37,33 +37,43 @@ const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction); const position = $derived.by(() => { - if (!menuElement) { + if (!menuScrollView || !menuElement) { return { left: 0, top: 0 }; } - const rect = menuElement.getBoundingClientRect(); + const rect = menuScrollView.getBoundingClientRect(); const directionWidth = layoutDirection === 'left' ? rect.width : 0; - const menuHeight = Math.min(menuElement.clientHeight, height) || 0; - const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth)); - const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y)); - const maxHeight = window.innerHeight - top - 8; + const margin = 8; - return { left, top, maxHeight }; + const left = Math.max(margin, Math.min(windowInnerWidth - rect.width - margin, x - directionWidth)); + const top = Math.max(margin, Math.min(windowInnerHeight - menuElement.clientHeight, y)); + const maxHeight = windowInnerHeight - top - margin; + + const needScrollBar = menuElement.clientHeight > maxHeight; + + return { left, top, maxHeight, needScrollBar }; }); - // We need to bind clientHeight since the bounding box may return a height - // of zero when starting the 'slide' animation. - let height: number = $state(0); + let windowInnerHeight: number = $state(0); + let windowInnerWidth: number = $state(0); + +