diff --git a/.github/package.json b/.github/package.json index 9b41cc7b4e..6fca2241ca 100644 --- a/.github/package.json +++ b/.github/package.json @@ -1,7 +1,7 @@ { "scripts": { - "format": "prettier --check .", - "format:fix": "prettier --write ." + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different ." }, "devDependencies": { "prettier": "^3.7.4" diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 2aaf73ef22..ca9f91bbe8 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -24,8 +24,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - # sha is pinning to a commit instead of a tag since the action does not tag versions - uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4 + uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/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/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 67e0b4b972..4f093a170e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 + uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: category: '/language:${{matrix.language}}' 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/.vscode/extensions.json b/.vscode/extensions.json index 8be57c6ba4..399fedae33 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,6 +5,13 @@ "dbaeumer.vscode-eslint", "dart-code.flutter", "dart-code.dart-code", - "dcmdev.dcm-vscode-extension" + "dcmdev.dcm-vscode-extension", + "bradlc.vscode-tailwindcss", + "ms-playwright.playwright", + "vitest.explorer", + "editorconfig.editorconfig", + "foxundermoon.shell-format", + "timonwong.shellcheck", + "bluebrown.yamlfmt" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 54c018259b..496e7539e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,7 @@ { "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[dart]": { "editor.defaultFormatter": "Dart-Code.dart-code", @@ -19,18 +18,15 @@ "source.removeUnusedImports": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[svelte]": { "editor.codeActionsOnSave": { @@ -38,8 +34,7 @@ "source.removeUnusedImports": "explicit" }, "editor.defaultFormatter": "svelte.svelte-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[typescript]": { "editor.codeActionsOnSave": { @@ -47,18 +42,45 @@ "source.removeUnusedImports": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "cSpell.words": ["immich"], + "css.lint.unknownAtRules": "ignore", + "editor.bracketPairColorization.enabled": true, "editor.formatOnSave": true, + "eslint.useFlatConfig": true, "eslint.validate": ["javascript", "typescript", "svelte"], + "eslint.workingDirectories": [ + { "directory": "cli", "changeProcessCWD": true }, + { "directory": "e2e", "changeProcessCWD": true }, + { "directory": "server", "changeProcessCWD": true }, + { "directory": "web", "changeProcessCWD": true } + ], + "files.watcherExclude": { + "**/.jj/**": true, + "**/.git/**": true, + "**/node_modules/**": true, + "**/build/**": true, + "**/dist/**": true, + "**/.svelte-kit/**": true + }, "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", "*.ts": "${capture}.spec.ts,${capture}.mock.ts", "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs" }, + "search.exclude": { + "**/node_modules": true, + "**/build": true, + "**/dist": true, + "**/.svelte-kit": true, + "**/open-api/typescript-sdk/src": true + }, "svelte.enable-ts-plugin": true, - "typescript.preferences.importModuleSpecifier": "non-relative" + "tailwindCSS.experimental.configFile": { + "web/src/app.css": "web/src/**" + }, + "js/ts.preferences.importModuleSpecifier": "non-relative", + "vitest.maximumConfigs": 10 } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1695403cb4..d04f89015e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,8 @@ Please try to keep pull requests as focused as possible. A PR should do exactly If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on! +We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR. + ## Use of generative AI We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request. diff --git a/cli/package.json b/cli/package.json index 849957ae36..d6202e6a1a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,8 +20,8 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^24.10.13", - "@vitest/coverage-v8": "^3.0.0", + "@types/node": "^24.11.0", + "@vitest/coverage-v8": "^4.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", @@ -37,7 +37,7 @@ "typescript-eslint": "^8.28.0", "vite": "^7.0.0", "vite-tsconfig-paths": "^6.0.0", - "vitest": "^3.0.0", + "vitest": "^4.0.0", "vitest-fetch-mock": "^0.4.0", "yaml": "^2.3.1" }, @@ -49,8 +49,8 @@ "prepack": "pnpm run build", "test": "vitest", "test:cov": "vitest --coverage", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "check": "tsc --noEmit" }, "repository": { diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index ea57eeb74b..21700ef963 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -1,6 +1,6 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { describe, expect, it, MockedFunction, vi } from 'vitest'; @@ -58,7 +58,7 @@ describe('uploadFiles', () => { }); it('returns new assets when upload file is successful', async () => { - fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => { + fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { return { status: 200, body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }), @@ -75,7 +75,7 @@ describe('uploadFiles', () => { it('returns new assets when upload file retry is successful', async () => { let counter = 0; - fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => { + fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { counter++; if (counter < retry) { throw new Error('Network error'); @@ -96,7 +96,7 @@ describe('uploadFiles', () => { }); it('returns new assets when upload file retry is failed', async () => { - fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => { + fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { throw new Error('Network error'); }); @@ -236,16 +236,19 @@ describe('startWatch', () => { await sleep(100); // to debounce the watcher from considering the test file as a existing file await fs.promises.writeFile(testFilePath, 'testjpg'); - await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); - expect(checkBulkUpload).toHaveBeenCalledWith({ - assetBulkUploadCheckDto: { - assets: [ - expect.objectContaining({ - id: testFilePath, - }), - ], - }, - }); + await vi.waitFor( + () => + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: [ + expect.objectContaining({ + id: testFilePath, + }), + ], + }, + }), + { timeout: 5000 }, + ); }); it('should filter out unsupported files', async () => { @@ -257,16 +260,19 @@ describe('startWatch', () => { await fs.promises.writeFile(testFilePath, 'testjpg'); await fs.promises.writeFile(unsupportedFilePath, 'testtxt'); - await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); - expect(checkBulkUpload).toHaveBeenCalledWith({ - assetBulkUploadCheckDto: { - assets: expect.arrayContaining([ - expect.objectContaining({ - id: testFilePath, - }), - ]), - }, - }); + await vi.waitFor( + () => + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }), + { timeout: 5000 }, + ); expect(checkBulkUpload).not.toHaveBeenCalledWith({ assetBulkUploadCheckDto: { @@ -291,16 +297,19 @@ describe('startWatch', () => { await fs.promises.writeFile(testFilePath, 'testjpg'); await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg'); - await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); - expect(checkBulkUpload).toHaveBeenCalledWith({ - assetBulkUploadCheckDto: { - assets: expect.arrayContaining([ - expect.objectContaining({ - id: testFilePath, - }), - ]), - }, - }); + await vi.waitFor( + () => + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }), + { timeout: 5000 }, + ); expect(checkBulkUpload).not.toHaveBeenCalledWith({ assetBulkUploadCheckDto: { diff --git a/cli/vite.config.ts b/cli/vite.config.ts index f538a9a357..c69b467011 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig, UserConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ @@ -17,4 +17,8 @@ export default defineConfig({ noExternal: /^(?!node:).*$/, }, plugins: [tsconfigPaths()], -}); + test: { + name: 'cli:unit', + globals: true, + }, +} as UserConfig); diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts deleted file mode 100644 index 7382f40e7d..0000000000 --- a/cli/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - }, -}); diff --git a/deployment/mise.toml b/deployment/mise.toml index d77ec84125..4f03e27ff7 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,6 +1,6 @@ [tools] -terragrunt = "0.98.0" -opentofu = "1.11.4" +terragrunt = "0.99.4" +opentofu = "1.11.5" [tasks."tg:fmt"] run = "terragrunt hclfmt" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8c46d3c51f..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:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + 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 4d9e7efbe9..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:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always @@ -85,7 +85,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702 + image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index f6eb38a429..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:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + 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 3d92655453..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:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docs/docs/administration/jobs-workers.md b/docs/docs/administration/jobs-workers.md index 74025f8ae8..dc2ca55bb9 100644 --- a/docs/docs/administration/jobs-workers.md +++ b/docs/docs/administration/jobs-workers.md @@ -67,7 +67,8 @@ graph TD C --> D["Thumbnail Generation (Large, small, blurred and person)"] D --> E[Smart Search] D --> F[Face Detection] - D --> G[Video Transcoding] - E --> H[Duplicate Detection] - F --> I[Facial Recognition] + D --> G[OCR] + D --> H[Video Transcoding] + E --> I[Duplicate Detection] + F --> J[Facial Recognition] ``` diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index fdfdad29ea..7dc9c08db3 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -230,7 +230,7 @@ The default value is `ultrafast`. ### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec} -Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`. +Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`. The default value is `aac`. diff --git a/docs/docs/developer/open-api.md b/docs/docs/api.md similarity index 99% rename from docs/docs/developer/open-api.md rename to docs/docs/api.md index f627b2c459..edf58dc94d 100644 --- a/docs/docs/developer/open-api.md +++ b/docs/docs/api.md @@ -1,4 +1,4 @@ -# OpenAPI +# API Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/). diff --git a/docs/docs/developer/architecture.mdx b/docs/docs/developer/architecture.mdx index 42d9c1b974..954d264d55 100644 --- a/docs/docs/developer/architecture.mdx +++ b/docs/docs/developer/architecture.mdx @@ -24,7 +24,7 @@ Immich has three main clients: 3. CLI - Command-line utility for bulk upload :::info -All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](./open-api.md). +All three clients use [OpenAPI](/api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](/api.md). ::: ### Mobile App @@ -71,7 +71,7 @@ An incoming HTTP request is mapped to a controller (`src/controllers`). Controll ### Domain Transfer Objects (DTOs) -The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](./open-api.md) schemas and control the generated code used by each client. +The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](/api.md) schemas and control the generated code used by each client. ### Background Jobs diff --git a/docs/docs/developer/pr-checklist.md b/docs/docs/developer/pr-checklist.md index e68567bc8f..e5dc6cc1e5 100644 --- a/docs/docs/developer/pr-checklist.md +++ b/docs/docs/developer/pr-checklist.md @@ -53,7 +53,7 @@ You can use `dart fix --apply` and `dcm fix lib` to potentially correct some iss ## OpenAPI -The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/developer/open-api.md) for more details. +The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details. ## Database Migrations diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index 059cf9a115..bd4fe49e96 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -50,6 +50,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele - The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`. - The ROCm image is quite large and requires at least 35GiB of free disk space. However, pulling later updates to the service through Docker will generally only amount to a few hundred megabytes as the rest will be cached. - This backend is new and may experience some issues. For example, GPU power consumption can be higher than usual after running inference, even if the machine learning service is idle. In this case, it will only go back to normal after being idle for 5 minutes (configurable with the [MACHINE_LEARNING_MODEL_TTL](/install/environment-variables) setting). +- MIGraphX is a new backend for AMD cards, which compiles models at runtime. As such, the first few inferences will be slow. #### OpenVINO diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index bf815521ef..3355750603 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -27,7 +27,7 @@ The default configuration looks like this: "ffmpeg": { "accel": "disabled", "accelDecode": false, - "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedAudioCodecs": ["aac", "mp3", "opus"], "acceptedContainers": ["mov", "ogg", "webm"], "acceptedVideoCodecs": ["h264"], "bframes": -1, diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 07b37f0e41..e9e3bb032c 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -166,6 +166,8 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning | | `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 147f981aff..00a120b8b6 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -6,7 +6,7 @@ const prism = require('prism-react-renderer'); /** @type {import('@docusaurus/types').Config} */ const config = { title: 'Immich', - tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone', + tagline: 'Self-hosted photo and video management solution', url: 'https://docs.immich.app', baseUrl: '/', onBrokenLinks: 'throw', @@ -93,35 +93,15 @@ const config = { position: 'right', }, { - to: '/overview/quick-start', + href: 'https://immich.app/', position: 'right', - label: 'Docs', - }, - { - href: 'https://immich.app/roadmap', - position: 'right', - label: 'Roadmap', - }, - { - href: 'https://api.immich.app/', - position: 'right', - label: 'API', - }, - { - href: 'https://immich.store', - position: 'right', - label: 'Merch', + label: 'Home', }, { href: 'https://github.com/immich-app/immich', label: 'GitHub', position: 'right', }, - { - href: 'https://discord.immich.app', - label: 'Discord', - position: 'right', - }, { type: 'html', position: 'right', @@ -134,19 +114,78 @@ const config = { style: 'light', links: [ { - title: 'Overview', + title: 'Download', items: [ { - label: 'Quick start', - to: '/overview/quick-start', + label: 'Android', + href: 'https://get.immich.app/android', }, { - label: 'Installation', - to: '/install/requirements', + label: 'iOS', + href: 'https://get.immich.app/ios', }, { - label: 'Contributing', - to: '/overview/support-the-project', + label: 'Server', + href: 'https://immich.app/download', + }, + ], + }, + { + title: 'Company', + items: [ + { + label: 'FUTO', + href: 'https://futo.tech/', + }, + { + label: 'Purchase', + href: 'https://buy.immich.app/', + }, + { + label: 'Merch', + href: 'https://immich.store/', + }, + ], + }, + { + title: 'Sites', + items: [ + { + label: 'Home', + href: 'https://immich.app', + }, + { + label: 'My Immich', + href: 'https://my.immich.app/', + }, + { + label: 'Awesome Immich', + href: 'https://awesome.immich.app/', + }, + { + label: 'Immich API', + href: 'https://api.immich.app/', + }, + { + label: 'Immich Data', + href: 'https://data.immich.app/', + }, + { + label: 'Immich Datasets', + href: 'https://datasets.immich.app/', + }, + ], + }, + { + title: 'Miscellaneous', + items: [ + { + label: 'Roadmap', + href: 'https://immich.app/roadmap', + }, + { + label: 'Cursed Knowledge', + href: 'https://immich.app/cursed-knowledge', }, { label: 'Privacy Policy', @@ -155,24 +194,7 @@ const config = { ], }, { - title: 'Documentation', - items: [ - { - label: 'Roadmap', - href: 'https://immich.app/roadmap', - }, - { - label: 'API', - href: 'https://api.immich.app/', - }, - { - label: 'Cursed Knowledge', - href: 'https://immich.app/cursed-knowledge', - }, - ], - }, - { - title: 'Links', + title: 'Social', items: [ { label: 'GitHub', diff --git a/docs/package.json b/docs/package.json index 8c270f013b..60a6dccf87 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "start": "docusaurus start --port 3005", "copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0", "build": "pnpm run copy:openapi && docusaurus build", diff --git a/docs/static/_redirects b/docs/static/_redirects index 5d4ad14f00..218bb71d69 100644 --- a/docs/static/_redirects +++ b/docs/static/_redirects @@ -23,6 +23,7 @@ /features/storage-template /administration/storage-template 307 /features/user-management /administration/user-management 307 /developer/contributing /developer/pr-checklist 307 +/developer/open-api /api 307 /guides/machine-learning /guides/remote-machine-learning 307 /administration/password-login /administration/system-settings 307 /features/search /features/searching 307 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 8ae5762a1b..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:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + 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 ac1ae081b3..34aedf3c46 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,8 +14,8 @@ "start:web": "pnpm exec playwright test --ui --project=web", "start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance", "start:web:ui": "pnpm exec playwright test --ui --project=ui", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", "lint:fix": "pnpm run lint --fix", "check": "tsc --noEmit" @@ -27,12 +27,12 @@ "@eslint/js": "^10.0.0", "@faker-js/faker": "^10.1.0", "@immich/cli": "workspace:*", - "@immich/e2e-auth-server": "workspace:*", + "@immich/e2e-auth-server": "workspace:*", "@immich/sdk": "workspace:*", "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.13", + "@types/node": "^24.11.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", @@ -54,7 +54,8 @@ "typescript": "^5.3.3", "typescript-eslint": "^8.28.0", "utimes": "^5.2.1", - "vitest": "^3.0.0" + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.0.0" }, "volta": { "node": "24.13.1" 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/asset-viewer/stack.e2e-spec.ts b/e2e/src/specs/web/asset-viewer/stack.e2e-spec.ts deleted file mode 100644 index cb40f82c0a..0000000000 --- a/e2e/src/specs/web/asset-viewer/stack.e2e-spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; -import { expect, Page, test } from '@playwright/test'; -import { utils } from 'src/utils'; - -async function ensureDetailPanelVisible(page: Page) { - await page.waitForSelector('#immich-asset-viewer'); - - const isVisible = await page.locator('#detail-panel').isVisible(); - if (!isVisible) { - await page.keyboard.press('i'); - await page.waitForSelector('#detail-panel'); - } -} - -test.describe('Asset Viewer stack', () => { - let admin: LoginResponseDto; - let assetOne: AssetMediaResponseDto; - let assetTwo: AssetMediaResponseDto; - - test.beforeAll(async () => { - utils.initSdk(); - await utils.resetDatabase(); - admin = await utils.adminSetup(); - await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } }); - - assetOne = await utils.createAsset(admin.accessToken); - assetTwo = await utils.createAsset(admin.accessToken); - await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]); - - const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']); - const tagOne = tags.find((tag) => tag.value === 'test/1')!; - const tagTwo = tags.find((tag) => tag.value === 'test/2')!; - await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]); - await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]); - }); - - test('stack slideshow is visible', async ({ page, context }) => { - await utils.setAuthCookies(context, admin.accessToken); - await page.goto(`/photos/${assetOne.id}`); - - const stackAssets = page.locator('#stack-slideshow [data-asset]'); - await expect(stackAssets.first()).toBeVisible(); - await expect(stackAssets.nth(1)).toBeVisible(); - }); - - test('tags of primary asset are visible', async ({ page, context }) => { - await utils.setAuthCookies(context, admin.accessToken); - await page.goto(`/photos/${assetOne.id}`); - await ensureDetailPanelVisible(page); - - const tags = page.getByTestId('detail-panel-tags').getByRole('link'); - await expect(tags.first()).toHaveText('test/1'); - }); - - test('tags of second asset are visible', async ({ page, context }) => { - await utils.setAuthCookies(context, admin.accessToken); - await page.goto(`/photos/${assetOne.id}`); - await ensureDetailPanelVisible(page); - - const stackAssets = page.locator('#stack-slideshow [data-asset]'); - await stackAssets.nth(1).click(); - - const tags = page.getByTestId('detail-panel-tags').getByRole('link'); - await expect(tags.first()).toHaveText('test/2'); - }); -}); 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/mock-network/broken-asset-network.ts b/e2e/src/ui/mock-network/broken-asset-network.ts new file mode 100644 index 0000000000..1494b40531 --- /dev/null +++ b/e2e/src/ui/mock-network/broken-asset-network.ts @@ -0,0 +1,167 @@ +import { faker } from '@faker-js/faker'; +import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk'; +import { BrowserContext } from '@playwright/test'; +import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline'; + +export type MockStack = { + id: string; + primaryAssetId: string; + assets: AssetResponseDto[]; + brokenAssetIds: Set; + assetMap: Map; +}; + +export const createMockStackAsset = (ownerId: string): AssetResponseDto => { + const assetId = faker.string.uuid(); + const now = new Date().toISOString(); + return { + id: assetId, + deviceAssetId: `device-${assetId}`, + ownerId, + owner: { + id: ownerId, + email: 'admin@immich.cloud', + name: 'Admin', + profileImagePath: '', + profileChangedAt: now, + avatarColor: 'blue' as never, + }, + libraryId: `library-${ownerId}`, + deviceId: `device-${ownerId}`, + type: AssetTypeEnum.Image, + originalPath: `/original/${assetId}.jpg`, + originalFileName: `${assetId}.jpg`, + originalMimeType: 'image/jpeg', + thumbhash: null, + fileCreatedAt: now, + fileModifiedAt: now, + localDateTime: now, + updatedAt: now, + createdAt: now, + isFavorite: false, + isArchived: false, + isTrashed: false, + visibility: AssetVisibility.Timeline, + duration: '0:00:00.00000', + exifInfo: { + make: null, + model: null, + exifImageWidth: 3000, + exifImageHeight: 4000, + fileSizeInByte: null, + orientation: null, + dateTimeOriginal: now, + modifyDate: null, + timeZone: null, + lensModel: null, + fNumber: null, + focalLength: null, + iso: null, + exposureTime: null, + latitude: null, + longitude: null, + city: null, + country: null, + state: null, + description: null, + }, + livePhotoVideoId: null, + tags: [], + people: [], + unassignedFaces: [], + stack: null, + isOffline: false, + hasMetadata: true, + duplicateId: null, + resized: true, + checksum: faker.string.alphanumeric({ length: 28 }), + width: 3000, + height: 4000, + isEdited: false, + }; +}; + +export const createMockStack = ( + primaryAssetDto: AssetResponseDto, + additionalAssets: AssetResponseDto[], + brokenAssetIds?: Set, +): MockStack => { + const stackId = faker.string.uuid(); + const allAssets = [primaryAssetDto, ...additionalAssets]; + const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id)); + const assetMap = new Map(allAssets.map((a) => [a.id, a])); + + primaryAssetDto.stack = { + id: stackId, + assetCount: allAssets.length, + primaryAssetId: primaryAssetDto.id, + }; + + return { + id: stackId, + primaryAssetId: primaryAssetDto.id, + assets: allAssets, + brokenAssetIds: resolvedBrokenIds, + assetMap, + }; +}; + +export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => { + await context.route('**/api/stacks/*', async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + const stackResponse: StackResponseDto = { + id: mockStack.id, + primaryAssetId: mockStack.primaryAssetId, + assets: mockStack.assets, + }; + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: stackResponse, + }); + }); + + await context.route('**/api/assets/*', async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + const url = new URL(request.url()); + const segments = url.pathname.split('/'); + const assetId = segments.at(-1); + if (assetId && mockStack.assetMap.has(assetId)) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: mockStack.assetMap.get(assetId), + }); + } + return route.fallback(); + }); + + await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => { + if (!route.request().serviceWorker()) { + return route.continue(); + } + const pattern = /\/api\/assets\/(?[^/]+)\/thumbnail\?size=(?preview|thumbnail)/; + const match = request.url().match(pattern); + if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) { + return route.fallback(); + } + if (mockStack.brokenAssetIds.has(match.groups.assetId)) { + return route.fulfill({ status: 404 }); + } + const asset = mockStack.assetMap.get(match.groups.assetId)!; + const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000); + const body = + match.groups.size === 'preview' + ? await randomPreview(match.groups.assetId, ratio) + : await randomThumbnail(match.groups.assetId, ratio); + return route.fulfill({ + status: 200, + headers: { 'content-type': 'image/jpeg' }, + body, + }); + }); +}; diff --git a/e2e/src/ui/mock-network/face-editor-network.ts b/e2e/src/ui/mock-network/face-editor-network.ts new file mode 100644 index 0000000000..778f04baf9 --- /dev/null +++ b/e2e/src/ui/mock-network/face-editor-network.ts @@ -0,0 +1,127 @@ +import { BrowserContext } from '@playwright/test'; +import { randomThumbnail } from 'src/ui/generators/timeline'; + +// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight +const MINIMAL_MP4_BASE64 = + 'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' + + '3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' + + 'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' + + 'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' + + 'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' + + 'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' + + 'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' + + 'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' + + 'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' + + 'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' + + 'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' + + 'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' + + 'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' + + 'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' + + 'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' + + 'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' + + 'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' + + 'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' + + 'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' + + 'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' + + 'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' + + '2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' + + 'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' + + 'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' + + 'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0'; + +export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64'); + +export type MockPerson = { + id: string; + name: string; + birthDate: string | null; + isHidden: boolean; + thumbnailPath: string; + updatedAt: string; +}; + +export const createMockPeople = (count: number): MockPerson[] => { + const names = [ + 'Alice Johnson', + 'Bob Smith', + 'Charlie Brown', + 'Diana Prince', + 'Eve Adams', + 'Frank Castle', + 'Grace Lee', + 'Hank Pym', + 'Iris West', + 'Jack Ryan', + ]; + return Array.from({ length: count }, (_, index) => ({ + id: `person-${index}`, + name: names[index % names.length], + birthDate: null, + isHidden: false, + thumbnailPath: `/upload/thumbs/person-${index}.jpeg`, + updatedAt: '2025-01-01T00:00:00.000Z', + })); +}; + +export type FaceCreateCapture = { + requests: Array<{ + assetId: string; + personId: string; + x: number; + y: number; + width: number; + height: number; + imageWidth: number; + imageHeight: number; + }>; +}; + +export const setupFaceEditorMockApiRoutes = async ( + context: BrowserContext, + mockPeople: MockPerson[], + faceCreateCapture: FaceCreateCapture, +) => { + await context.route('**/api/people?*', async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + hasNextPage: false, + hidden: 0, + people: mockPeople, + total: mockPeople.length, + }, + }); + }); + + await context.route('**/api/faces', async (route, request) => { + if (request.method() !== 'POST') { + return route.fallback(); + } + + const body = request.postDataJSON(); + faceCreateCapture.requests.push(body); + + return route.fulfill({ + status: 201, + contentType: 'text/plain', + body: 'OK', + }); + }); + + await context.route('**/api/people/*/thumbnail', async (route) => { + if (!route.request().serviceWorker()) { + return route.continue(); + } + return route.fulfill({ + status: 200, + headers: { 'content-type': 'image/jpeg' }, + body: await randomThumbnail('person-thumb', 1), + }); + }); +}; diff --git a/e2e/src/ui/mock-network/timeline-network.ts b/e2e/src/ui/mock-network/timeline-network.ts index b20a812eb1..6af2ebb7c1 100644 --- a/e2e/src/ui/mock-network/timeline-network.ts +++ b/e2e/src/ui/mock-network/timeline-network.ts @@ -12,6 +12,7 @@ import { TimelineData, } from 'src/ui/generators/timeline'; import { sleep } from 'src/ui/specs/timeline/utils'; +import { MINIMAL_MP4_BUFFER } from './face-editor-network'; export class TimelineTestContext { slowBucket = false; @@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async ( return route.continue(); }); + await context.route('**/api/assets/*/video/playback*', async (route) => { + return route.fulfill({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + body: MINIMAL_MP4_BUFFER, + }); + }); + await context.route('**/api/albums/**', async (route, request) => { const albumsMatch = request.url().match(/\/api\/albums\/(?[^/?]+)/); if (albumsMatch) { 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 new file mode 100644 index 0000000000..2b036d3f52 --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import { toAssetResponseDto } from 'src/ui/generators/timeline'; +import { + createMockStack, + createMockStackAsset, + MockStack, + setupBrokenAssetMockApiRoutes, +} from 'src/ui/mock-network/broken-asset-network'; +import { assetViewerUtils } from '../timeline/utils'; +import { setupAssetViewerFixture } from './utils'; + +test.describe.configure({ mode: 'parallel' }); +test.describe('broken-asset responsiveness', () => { + const fixture = setupAssetViewerFixture(889); + let mockStack: MockStack; + + test.beforeAll(async () => { + const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset); + + const brokenAssets = [ + createMockStackAsset(fixture.adminUserId), + createMockStackAsset(fixture.adminUserId), + createMockStackAsset(fixture.adminUserId), + ]; + + mockStack = createMockStack(primaryAssetDto, brokenAssets); + }); + + test.beforeEach(async ({ context }) => { + await setupBrokenAssetMockApiRoutes(context, mockStack); + }); + + test('broken asset in stack strip hides icon at small size', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const stackSlideshow = page.locator('#stack-slideshow'); + await expect(stackSlideshow).toBeVisible(); + + const brokenAssets = stackSlideshow.locator('[data-broken-asset]'); + await expect(brokenAssets.first()).toBeVisible(); + await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size); + + for (const brokenAsset of await brokenAssets.all()) { + await expect(brokenAsset.locator('svg')).not.toBeVisible(); + } + }); + + test('broken asset in stack strip uses text-xs class', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const stackSlideshow = page.locator('#stack-slideshow'); + await expect(stackSlideshow).toBeVisible(); + + const brokenAssets = stackSlideshow.locator('[data-broken-asset]'); + await expect(brokenAssets.first()).toBeVisible(); + + for (const brokenAsset of await brokenAssets.all()) { + const messageSpan = brokenAsset.locator('span'); + await expect(messageSpan).toHaveClass(/text-xs/); + } + }); + + 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.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`), + async (route) => { + return route.fulfill({ status: 404 }); + }, + ); + + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await page.waitForSelector('#immich-asset-viewer'); + + const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first(); + await expect(viewerBrokenAsset).toBeVisible(); + + await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); + + const messageSpan = viewerBrokenAsset.locator('span'); + await expect(messageSpan).toHaveClass(/text-base/); + }); +}); diff --git a/e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts new file mode 100644 index 0000000000..b1058f646e --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts @@ -0,0 +1,285 @@ +import { expect, Page, test } from '@playwright/test'; +import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline'; +import { + createMockPeople, + FaceCreateCapture, + MockPerson, + setupFaceEditorMockApiRoutes, +} from 'src/ui/mock-network/face-editor-network'; +import { assetViewerUtils } from '../timeline/utils'; +import { setupAssetViewerFixture } from './utils'; + +const waitForSelectorTransition = async (page: Page) => { + await page.waitForFunction( + () => { + const selector = document.querySelector('#face-selector') as HTMLElement | null; + if (!selector) { + return false; + } + return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished'); + }, + undefined, + { timeout: 1000, polling: 50 }, + ); +}; + +const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => { + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.keyboard.press('i'); + await page.locator('#detail-panel').waitFor({ state: 'visible' }); + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + await waitForSelectorTransition(page); +}; + +test.describe.configure({ mode: 'parallel' }); +test.describe('face-editor', () => { + const fixture = setupAssetViewerFixture(777); + const rng = new SeededRandom(777); + let mockPeople: MockPerson[]; + let faceCreateCapture: FaceCreateCapture; + + test.beforeAll(async () => { + mockPeople = createMockPeople(8); + }); + + test.beforeEach(async ({ context }) => { + faceCreateCapture = { requests: [] }; + await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture); + }); + + type ScreenRect = { top: number; left: number; width: number; height: number }; + + const getFaceBoxRect = async (page: Page): Promise => { + const dataEl = page.locator('#face-editor-data'); + await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/); + await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/); + await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/); + await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/); + const canvasBox = await page.locator('#face-editor').boundingBox(); + if (!canvasBox) { + throw new Error('Canvas element not found'); + } + const left = Number(await dataEl.getAttribute('data-face-left')); + const top = Number(await dataEl.getAttribute('data-face-top')); + const width = Number(await dataEl.getAttribute('data-face-width')); + const height = Number(await dataEl.getAttribute('data-face-height')); + return { + top: canvasBox.y + top, + left: canvasBox.x + left, + width, + height, + }; + }; + + const getSelectorRect = async (page: Page): Promise => { + const box = await page.locator('#face-selector').boundingBox(); + if (!box) { + throw new Error('Face selector element not found'); + } + return { top: box.y, left: box.x, width: box.width, height: box.height }; + }; + + const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => { + const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left)); + const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top)); + return overlapX * overlapY; + }; + + const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => { + const faceBox = await getFaceBoxRect(page); + const centerX = faceBox.left + faceBox.width / 2; + const centerY = faceBox.top + faceBox.height / 2; + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 }); + await page.mouse.up(); + await page.waitForTimeout(300); + }; + + test('Face editor opens with person list', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + await expect(page.locator('#face-selector')).toBeVisible(); + await expect(page.locator('#face-editor')).toBeVisible(); + + for (const person of mockPeople) { + await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible(); + } + }); + + test('Search filters people by name', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const searchInput = page.locator('#face-selector input'); + await searchInput.fill('Alice'); + + await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible(); + await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden(); + + await searchInput.clear(); + + for (const person of mockPeople) { + await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible(); + } + }); + + test('Search with no results shows empty message', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const searchInput = page.locator('#face-selector input'); + await searchInput.fill('Nonexistent Person XYZ'); + + for (const person of mockPeople) { + await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden(); + } + }); + + test('Selecting a person shows confirmation dialog', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const personToTag = mockPeople[0]; + await page.locator('#face-selector').getByText(personToTag.name).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + }); + + test('Confirming tag calls createFace API and closes editor', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const personToTag = mockPeople[0]; + await page.locator('#face-selector').getByText(personToTag.name).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: /confirm/i }).click(); + + await expect(page.locator('#face-selector')).toBeHidden(); + await expect(page.locator('#face-editor')).toBeHidden(); + + expect(faceCreateCapture.requests).toHaveLength(1); + expect(faceCreateCapture.requests[0].assetId).toBe(asset.id); + expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id); + }); + + test('Cancel button closes face editor', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + await expect(page.locator('#face-selector')).toBeVisible(); + await expect(page.locator('#face-editor')).toBeVisible(); + + await page.getByRole('button', { name: /cancel/i }).click(); + + await expect(page.locator('#face-selector')).toBeHidden(); + await expect(page.locator('#face-editor')).toBeHidden(); + }); + + test('Selector does not overlap face box on initial open', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const faceBox = await getFaceBoxRect(page); + const selectorBox = await getSelectorRect(page); + const overlap = computeOverlapArea(faceBox, selectorBox); + + expect(overlap).toBe(0); + }); + + test('Selector repositions without overlap after dragging face box down', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + await dragFaceBox(page, 0, 150); + + const faceBox = await getFaceBoxRect(page); + const selectorBox = await getSelectorRect(page); + const overlap = computeOverlapArea(faceBox, selectorBox); + + expect(overlap).toBe(0); + }); + + test('Selector repositions without overlap after dragging face box right', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + await dragFaceBox(page, 200, 0); + + const faceBox = await getFaceBoxRect(page); + const selectorBox = await getSelectorRect(page); + const overlap = computeOverlapArea(faceBox, selectorBox); + + expect(overlap).toBe(0); + }); + + test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + await dragFaceBox(page, -300, -300); + + const faceBox = await getFaceBoxRect(page); + const selectorBox = await getSelectorRect(page); + const overlap = computeOverlapArea(faceBox, selectorBox); + + expect(overlap).toBe(0); + }); + + test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + await dragFaceBox(page, 300, 300); + + const faceBox = await getFaceBoxRect(page); + const selectorBox = await getSelectorRect(page); + const overlap = computeOverlapArea(faceBox, selectorBox); + + expect(overlap).toBe(0); + }); + + test('Selector stays within viewport bounds', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const viewportSize = page.viewportSize()!; + const selectorBox = await getSelectorRect(page); + + expect(selectorBox.top).toBeGreaterThanOrEqual(0); + expect(selectorBox.left).toBeGreaterThanOrEqual(0); + expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height); + expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width); + }); + + test('Selector stays within viewport after dragging to edge', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + await dragFaceBox(page, -400, -400); + + const viewportSize = page.viewportSize()!; + const selectorBox = await getSelectorRect(page); + + expect(selectorBox.top).toBeGreaterThanOrEqual(0); + expect(selectorBox.left).toBeGreaterThanOrEqual(0); + expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height); + expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width); + }); + + test('Face box is draggable on the canvas', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const beforeDrag = await getFaceBoxRect(page); + await dragFaceBox(page, 100, 50); + const afterDrag = await getFaceBoxRect(page); + + expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50); + expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20); + }); +}); diff --git a/e2e/src/ui/specs/asset-viewer/stack.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/stack.e2e-spec.ts new file mode 100644 index 0000000000..976a44dd9e --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/stack.e2e-spec.ts @@ -0,0 +1,84 @@ +import { faker } from '@faker-js/faker'; +import type { AssetResponseDto } from '@immich/sdk'; +import { expect, test } from '@playwright/test'; +import { toAssetResponseDto } from 'src/ui/generators/timeline'; +import { + createMockStack, + createMockStackAsset, + MockStack, + setupBrokenAssetMockApiRoutes, +} from 'src/ui/mock-network/broken-asset-network'; +import { assetViewerUtils } from '../timeline/utils'; +import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils'; + +test.describe.configure({ mode: 'parallel' }); +test.describe('asset-viewer stack', () => { + const fixture = setupAssetViewerFixture(888); + let mockStack: MockStack; + let primaryAssetDto: AssetResponseDto; + let secondAssetDto: AssetResponseDto; + + test.beforeAll(async () => { + primaryAssetDto = toAssetResponseDto(fixture.primaryAsset); + primaryAssetDto.tags = [ + { + id: faker.string.uuid(), + name: '1', + value: 'test/1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]; + + secondAssetDto = createMockStackAsset(fixture.adminUserId); + secondAssetDto.tags = [ + { + id: faker.string.uuid(), + name: '2', + value: 'test/2', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]; + + mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set()); + }); + + test.beforeEach(async ({ context }) => { + await setupBrokenAssetMockApiRoutes(context, mockStack); + }); + + test('stack slideshow is visible', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const stackSlideshow = page.locator('#stack-slideshow'); + await expect(stackSlideshow).toBeVisible(); + + const stackAssets = stackSlideshow.locator('[data-asset]'); + await expect(stackAssets).toHaveCount(mockStack.assets.length); + }); + + test('tags of primary asset are visible', async ({ context, page }) => { + await enableTagsPreference(context); + + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await ensureDetailPanelVisible(page); + + const tags = page.getByTestId('detail-panel-tags').getByRole('link'); + await expect(tags.first()).toHaveText('test/1'); + }); + + test('tags of second asset are visible', async ({ context, page }) => { + await enableTagsPreference(context); + + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await ensureDetailPanelVisible(page); + + const stackAssets = page.locator('#stack-slideshow [data-asset]'); + await stackAssets.nth(1).click(); + + const tags = page.getByTestId('detail-panel-tags').getByRole('link'); + await expect(tags.first()).toHaveText('test/2'); + }); +}); diff --git a/e2e/src/ui/specs/asset-viewer/utils.ts b/e2e/src/ui/specs/asset-viewer/utils.ts new file mode 100644 index 0000000000..adaace8a34 --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/utils.ts @@ -0,0 +1,116 @@ +import { faker } from '@faker-js/faker'; +import type { AssetResponseDto } from '@immich/sdk'; +import { BrowserContext, Page, test } from '@playwright/test'; +import { + Changes, + createDefaultTimelineConfig, + generateTimelineData, + SeededRandom, + selectRandom, + TimelineAssetConfig, + TimelineData, + toAssetResponseDto, +} from 'src/ui/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; +import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; +import { utils } from 'src/utils'; + +export type AssetViewerTestFixture = { + adminUserId: string; + timelineRestData: TimelineData; + assets: TimelineAssetConfig[]; + testContext: TimelineTestContext; + changes: Changes; + primaryAsset: TimelineAssetConfig; + primaryAssetDto: AssetResponseDto; +}; + +export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture { + const rng = new SeededRandom(seed); + const testContext = new TimelineTestContext(); + + const fixture: AssetViewerTestFixture = { + adminUserId: undefined!, + timelineRestData: undefined!, + assets: [], + testContext, + changes: { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }, + primaryAsset: undefined!, + primaryAssetDto: undefined!, + }; + + test.beforeAll(async () => { + test.fail( + process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1', + 'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1', + ); + utils.initSdk(); + fixture.adminUserId = faker.string.uuid(); + testContext.adminId = fixture.adminUserId; + fixture.timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: fixture.adminUserId, + }); + for (const timeBucket of fixture.timelineRestData.buckets.values()) { + fixture.assets.push(...timeBucket); + } + + fixture.primaryAsset = selectRandom( + fixture.assets.filter((a) => a.isImage), + rng, + ); + fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, fixture.adminUserId); + await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext); + }); + + test.afterEach(() => { + fixture.testContext.slowBucket = false; + fixture.changes.albumAdditions = []; + fixture.changes.assetDeletions = []; + fixture.changes.assetArchivals = []; + fixture.changes.assetFavorites = []; + }); + + return fixture; +} + +export async function ensureDetailPanelVisible(page: Page) { + await page.waitForSelector('#immich-asset-viewer'); + + const isVisible = await page.locator('#detail-panel').isVisible(); + if (!isVisible) { + await page.keyboard.press('i'); + await page.waitForSelector('#detail-panel'); + } +} + +export async function enableTagsPreference(context: BrowserContext) { + await context.route('**/users/me/preferences', async (route) => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + albums: { defaultAssetOrder: 'desc' }, + folders: { enabled: false, sidebarWeb: false }, + memories: { enabled: true, duration: 5 }, + people: { enabled: true, sidebarWeb: false }, + sharedLinks: { enabled: true, sidebarWeb: false }, + ratings: { enabled: false }, + tags: { enabled: true, sidebarWeb: false }, + emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true }, + download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false }, + purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' }, + cast: { gCastEnabled: false }, + }, + }); + }); +} diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index d3e4e5f7ec..b7003295cf 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -215,8 +215,9 @@ export const pageUtils = { await page.getByText('Confirm').click(); }, async selectDay(page: Page, day: string) { - await page.getByTitle(day).hover(); - await page.locator('[data-group] .w-8').click(); + const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]'); + await section.hover(); + await section.locator('.w-8').click(); }, async pauseTestDebug() { console.log('NOTE: pausing test indefinately for debug'); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 7307f87854..a5567f0778 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -177,40 +177,51 @@ export const utils = { }, resetDatabase: async (tables?: string[]) => { - try { - client = await utils.connectDatabase(); + client = await utils.connectDatabase(); - tables = tables || [ - // TODO e2e test for deleting a stack, since it is quite complex - 'stack', - 'library', - 'shared_link', - 'person', - 'album', - 'asset', - 'asset_face', - 'activity', - 'api_key', - 'session', - 'user', - 'system_metadata', - 'tag', - ]; + tables = tables || [ + // TODO e2e test for deleting a stack, since it is quite complex + 'stack', + 'library', + 'shared_link', + 'person', + 'album', + 'asset', + 'asset_face', + 'activity', + 'api_key', + 'session', + 'user', + 'system_metadata', + 'tag', + ]; - const sql: string[] = []; + const truncateTables = tables.filter((table) => table !== 'system_metadata'); + const sql: string[] = []; - for (const table of tables) { - if (table === 'system_metadata') { - sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); - } else { - sql.push(`DELETE FROM "${table}" CASCADE;`); + if (truncateTables.length > 0) { + sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`); + } + + if (tables.includes('system_metadata')) { + sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); + } + + const query = sql.join('\n'); + const maxRetries = 3; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await client.query(query); + return; + } catch (error: any) { + if (error?.code === '40P01' && attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 250 * attempt)); + continue; } + console.error('Failed to reset database', error); + throw error; } - - await client.query(sql.join('\n')); - } catch (error) { - console.error('Failed to reset database', error); - throw error; } }, diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index bfad377089..f6efbf41e9 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -17,6 +17,6 @@ "esModuleInterop": true, "baseUrl": "./" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "vitest*.config.ts"], "exclude": ["dist", "node_modules"] } diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 1312bf9b75..17ece152d7 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,3 +1,4 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; @@ -14,15 +15,14 @@ if (!skipDockerSetup) { export default defineConfig({ test: { + name: 'e2e:server', retry: process.env.CI ? 4 : 0, include: ['src/specs/server/**/*.e2e-spec.ts'], globalSetup, testTimeout: 15_000, pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, + isolate: false, }, + plugins: [tsconfigPaths()], }); diff --git a/e2e/vitest.maintenance.config.ts b/e2e/vitest.maintenance.config.ts index 6bb6721a6d..a6e96ccc0a 100644 --- a/e2e/vitest.maintenance.config.ts +++ b/e2e/vitest.maintenance.config.ts @@ -1,3 +1,4 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; @@ -14,15 +15,14 @@ if (!skipDockerSetup) { export default defineConfig({ test: { + name: 'e2e:maintenance', retry: process.env.CI ? 4 : 0, include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'], globalSetup, testTimeout: 15_000, pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, + isolate: false, }, + plugins: [tsconfigPaths()], }); diff --git a/i18n/en.json b/i18n/en.json index b99dac5609..c3b998ec13 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -411,7 +411,7 @@ "transcoding_tone_mapping": "Tone-mapping", "transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.", "transcoding_transcode_policy": "Transcode policy", - "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).", + "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos and videos with a pixel format other than YUV 4:2:0 will always be transcoded (except if transcoding is disabled).", "transcoding_two_pass_encoding": "Two-pass encoding", "transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.", "transcoding_video_codec": "Video codec", @@ -871,8 +871,8 @@ "current_pin_code": "Current PIN code", "current_server_address": "Current server address", "custom_date": "Custom date", - "custom_locale": "Custom Locale", - "custom_locale_description": "Format dates and numbers based on the language and the region", + "custom_locale": "Custom locale", + "custom_locale_description": "Format dates, times, and numbers based on the selected language and region", "custom_url": "Custom URL", "cutoff_date_description": "Keep photos from the lastâ€Ļ", "cutoff_day": "{count, plural, one {day} other {days}}", @@ -895,8 +895,6 @@ "deduplication_criteria_2": "Count of EXIF data", "deduplication_info": "Deduplication Info", "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:", - "default_locale": "Default Locale", - "default_locale_description": "Format dates and numbers based on your browser locale", "delete": "Delete", "delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally", "delete_action_prompt": "{count} deleted", @@ -1009,6 +1007,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 +1074,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", @@ -1810,9 +1810,8 @@ "rate_asset": "Rate Asset", "rating": "Star rating", "rating_clear": "Clear rating", - "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}", "rating_description": "Display the EXIF rating in the info panel", - "rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "readonly_mode_disabled": "Read-only mode disabled", @@ -1884,7 +1883,10 @@ "reset_pin_code_success": "Successfully reset PIN code", "reset_pin_code_with_password": "You can always reset your PIN code with your password", "reset_sqlite": "Reset SQLite Database", - "reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data", + "reset_sqlite_clear_app_data": "Clear Data", + "reset_sqlite_confirmation": "Are you sure you want to clear the app data? This will remove all settings and sign you out.", + "reset_sqlite_confirmation_note": "Note: You will need to restart the app after clearing.", + "reset_sqlite_done": "App data has been cleared. Please restart Immich and log in again.", "reset_sqlite_success": "Successfully reset the SQLite database", "reset_to_default": "Reset to default", "resolution": "Resolution", @@ -1912,6 +1914,7 @@ "saved_settings": "Saved settings", "say_something": "Say something", "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_unrecoverable": "An unrecoverable error has occurred. Please share the error and stack trace on Discord or GitHub so we can help. If advised, you can clear the app data below.", "scan": "Scan", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", @@ -2335,6 +2338,8 @@ "url": "URL", "usage": "Usage", "use_biometric": "Use biometric", + "use_browser_locale": "Use browser locale", + "use_browser_locale_description": "Format dates, times, and numbers based on your browser locale", "use_current_connection": "Use current connection", "use_custom_date_range": "Use custom date range instead", "user": "User", diff --git a/i18n/package.json b/i18n/package.json index 47748c28e8..4d4aa7965c 100644 --- a/i18n/package.json +++ b/i18n/package.json @@ -3,8 +3,8 @@ "version": "2.5.6", "private": true, "scripts": { - "format": "prettier --check .", - "format:fix": "prettier --write ." + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different ." }, "devDependencies": { "prettier": "^3.7.4", diff --git a/machine-learning/.python-version b/machine-learning/.python-version new file mode 100644 index 0000000000..24ee5b1be9 --- /dev/null +++ b/machine-learning/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index efbe710dbc..89480a8cb8 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -22,48 +22,7 @@ FROM builder-cpu AS builder-rknn # Warning: 25GiB+ disk space required to pull this image # TODO: find a way to reduce the image size -FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm - -# renovate: datasource=github-releases depName=Microsoft/onnxruntime -ARG ONNXRUNTIME_VERSION="v1.22.1" -WORKDIR /code - -RUN apt-get update && apt-get install -y --no-install-recommends wget git -RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \ - chmod +x cmake-3.31.9-linux-x86_64.sh && \ - mkdir -p /code/cmake-3.31.9-linux-x86_64 && \ - ./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \ - rm cmake-3.31.9-linux-x86_64.sh - -RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime -WORKDIR /code/onnxruntime -# Fix for multi-threading based on comments in https://github.com/microsoft/onnxruntime/pull/19567 -# TODO: find a way to fix this without disabling algo caching -COPY ./patches/* /tmp/ -RUN git apply /tmp/*.patch - -RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh - -ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH} -ENV CCACHE_DIR="/ccache" -# Note: the `parallel` setting uses a substantial amount of RAM -RUN --mount=type=cache,target=/ccache \ - ./build.sh \ - --allow_running_as_root \ - --config Release \ - --build_wheel \ - --update \ - --build \ - --parallel 48 \ - --cmake_extra_defines \ - ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \ - CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \ - --skip_tests \ - --use_rocm \ - --rocm_home=/opt/rocm \ - --use_cache \ - --compile_no_warning_as_error -RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/ +FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS builder-rocm FROM builder-${DEVICE} AS builder @@ -79,9 +38,6 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy -RUN if [ "$DEVICE" = "rocm" ]; then \ - uv pip install /opt/onnxruntime_rocm-*.whl; \ - fi FROM python:3.11-slim-bookworm@sha256:04cd27899595a99dfe77709d96f08876bf2ee99139ee2f0fe9ac948005034e5b AS prod-cpu @@ -92,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3 RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ - wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \ - wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \ - wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \ + wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \ + wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \ + wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \ # TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file - wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \ + wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \ dpkg -i *.deb && \ rm *.deb && \ apt-get remove wget -yqq && \ @@ -120,7 +76,11 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3 COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11 COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so -FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm +FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS prod-rocm + +RUN apt-get update && apt-get install --no-install-recommends -yqq migraphx miopen-hip && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* FROM prod-cpu AS prod-armnn diff --git a/machine-learning/immich_ml/config.py b/machine-learning/immich_ml/config.py index 19fd5300df..8b383f5419 100644 --- a/machine-learning/immich_ml/config.py +++ b/machine-learning/immich_ml/config.py @@ -48,8 +48,11 @@ class PreloadModelData(BaseModel): class MaxBatchSize(BaseModel): + ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None) + if ocr_fallback is not None: + os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback facial_recognition: int | None = None - text_recognition: int | None = None + ocr: int | None = None class Settings(BaseSettings): @@ -79,6 +82,7 @@ class Settings(BaseSettings): preload: PreloadModelData | None = None max_batch_size: MaxBatchSize | None = None openvino_precision: ModelPrecision = ModelPrecision.FP32 + rocm_precision: ModelPrecision = ModelPrecision.FP32 @property def device_id(self) -> str: diff --git a/machine-learning/immich_ml/models/constants.py b/machine-learning/immich_ml/models/constants.py index db9e7cfa4d..0815410495 100644 --- a/machine-learning/immich_ml/models/constants.py +++ b/machine-learning/immich_ml/models/constants.py @@ -90,7 +90,7 @@ _PADDLE_MODELS = { SUPPORTED_PROVIDERS = [ "CUDAExecutionProvider", - "ROCMExecutionProvider", + "MIGraphXExecutionProvider", "OpenVINOExecutionProvider", "CoreMLExecutionProvider", "CPUExecutionProvider", diff --git a/machine-learning/immich_ml/models/facial_recognition/recognition.py b/machine-learning/immich_ml/models/facial_recognition/recognition.py index 759992a600..ed1897c9f9 100644 --- a/machine-learning/immich_ml/models/facial_recognition/recognition.py +++ b/machine-learning/immich_ml/models/facial_recognition/recognition.py @@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel): def __init__(self, model_name: str, **model_kwargs: Any) -> None: super().__init__(model_name, **model_kwargs) - max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None + max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition self.batch_size = max_batch_size if max_batch_size else self._batch_size_default def _load(self) -> ModelSession: diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index d34a51684e..0a2cb8ad91 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -22,7 +22,7 @@ class TextDetector(InferenceModel): depends = [] identity = (ModelType.DETECTION, ModelTask.OCR) - def __init__(self, model_name: str, **model_kwargs: Any) -> None: + def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None: super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX) self.max_resolution = 736 self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32) @@ -33,7 +33,7 @@ class TextDetector(InferenceModel): } self.postprocess = DBPostProcess( thresh=0.3, - box_thresh=model_kwargs.get("minScore", 0.5), + box_thresh=model_kwargs.get("minScore", min_score), max_candidates=1000, unclip_ratio=1.6, use_dilation=True, diff --git a/machine-learning/immich_ml/models/ocr/recognition.py b/machine-learning/immich_ml/models/ocr/recognition.py index e968392881..6408e4818f 100644 --- a/machine-learning/immich_ml/models/ocr/recognition.py +++ b/machine-learning/immich_ml/models/ocr/recognition.py @@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel): depends = [(ModelType.DETECTION, ModelTask.OCR)] identity = (ModelType.RECOGNITION, ModelTask.OCR) - def __init__(self, model_name: str, **model_kwargs: Any) -> None: + def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None: self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH - self.min_score = model_kwargs.get("minScore", 0.9) + self.min_score = model_kwargs.get("minScore", min_score) self._empty: TextRecognitionOutput = { "box": np.empty(0, dtype=np.float32), "boxScore": np.empty(0, dtype=np.float32), @@ -57,10 +57,11 @@ class TextRecognizer(InferenceModel): def _load(self) -> ModelSession: # TODO: support other runtimes session = OrtSession(self.model_path) + max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr self.model = RapidTextRecognizer( OcrOptions( session=session.session, - rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6, + rec_batch_num=max_batch_size if max_batch_size else 6, rec_img_shape=(3, 48, 320), lang_type=self.language, ) diff --git a/machine-learning/immich_ml/sessions/ort.py b/machine-learning/immich_ml/sessions/ort.py index 6c52936722..bebd235970 100644 --- a/machine-learning/immich_ml/sessions/ort.py +++ b/machine-learning/immich_ml/sessions/ort.py @@ -8,7 +8,7 @@ import onnxruntime as ort from numpy.typing import NDArray from immich_ml.models.constants import SUPPORTED_PROVIDERS -from immich_ml.schemas import SessionNode +from immich_ml.schemas import ModelPrecision, SessionNode from ..config import log, settings @@ -64,14 +64,6 @@ class OrtSession: def _providers_default(self) -> list[str]: available_providers = set(ort.get_available_providers()) log.debug(f"Available ORT providers: {available_providers}") - if (openvino := "OpenVINOExecutionProvider") in available_providers: - device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids() - log.debug(f"Available OpenVINO devices: {device_ids}") - - gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")] - if not gpu_devices: - log.warning("No GPU device found in OpenVINO. Falling back to CPU.") - available_providers.remove(openvino) return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers] @property @@ -90,15 +82,31 @@ class OrtSession: match provider: case "CPUExecutionProvider": options = {"arena_extend_strategy": "kSameAsRequested"} - case "CUDAExecutionProvider" | "ROCMExecutionProvider": + case "CUDAExecutionProvider": options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id} - case "OpenVINOExecutionProvider": - openvino_dir = self.model_path.parent / "openvino" - device = f"GPU.{settings.device_id}" + case "MIGraphXExecutionProvider": + migraphx_dir = self.model_path.parent / "migraphx" + # MIGraphX does not create the underlying folder and will crash if it does not exist + migraphx_dir.mkdir(parents=True, exist_ok=True) options = { - "device_type": device, + "device_id": settings.device_id, + "migraphx_model_cache_dir": migraphx_dir.as_posix(), + "migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0", + } + case "OpenVINOExecutionProvider": + device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids() + # Check for available devices, preferring GPU over CPU + gpu_devices = [d for d in device_ids if d.startswith("GPU")] + if gpu_devices: + device_type = f"GPU.{settings.device_id}" + log.debug(f"OpenVINO: Using GPU device {device_type}") + else: + device_type = "CPU" + log.debug("OpenVINO: No GPU found, using CPU") + options = { + "device_type": device_type, "precision": settings.openvino_precision.value, - "cache_dir": openvino_dir.as_posix(), + "cache_dir": (self.model_path.parent / "openvino").as_posix(), } case "CoreMLExecutionProvider": options = { @@ -130,12 +138,14 @@ class OrtSession: sess_options.enable_cpu_mem_arena = settings.model_arena # avoid thread contention between models + # Set inter_op threads if settings.model_inter_op_threads > 0: sess_options.inter_op_num_threads = settings.model_inter_op_threads # these defaults work well for CPU, but bottleneck GPU elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]: sess_options.inter_op_num_threads = 1 + # Set intra_op threads if settings.model_intra_op_threads > 0: sess_options.intra_op_num_threads = settings.model_intra_op_threads elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]: diff --git a/machine-learning/patches/0001-disable-rocm-conv-algo-caching.patch b/machine-learning/patches/0001-disable-rocm-conv-algo-caching.patch deleted file mode 100644 index 6627f67778..0000000000 --- a/machine-learning/patches/0001-disable-rocm-conv-algo-caching.patch +++ /dev/null @@ -1,179 +0,0 @@ -commit 16839b58d9b3c3162a67ce5d776b36d4d24e801f -Author: mertalev <101130780+mertalev@users.noreply.github.com> -Date: Wed Mar 5 11:25:38 2025 -0500 - - disable algo caching (attributed to @dmnieto in https://github.com/microsoft/onnxruntime/pull/19567) - -diff --git a/onnxruntime/core/providers/rocm/nn/conv.cc b/onnxruntime/core/providers/rocm/nn/conv.cc -index d7f47d07a8..4060a2af52 100644 ---- a/onnxruntime/core/providers/rocm/nn/conv.cc -+++ b/onnxruntime/core/providers/rocm/nn/conv.cc -@@ -127,7 +127,6 @@ Status Conv::UpdateState(OpKernelContext* context, bool bias_expected) - - if (w_dims_changed) { - s_.last_w_dims = gsl::make_span(w_dims); -- s_.cached_benchmark_fwd_results.clear(); - } - - ORT_RETURN_IF_ERROR(conv_attrs_.ValidateInputShape(X->Shape(), W->Shape(), channels_last, channels_last)); -@@ -277,35 +276,6 @@ Status Conv::UpdateState(OpKernelContext* context, bool bias_expected) - HIP_CALL_THROW(hipMalloc(&s_.b_zero, malloc_size)); - HIP_CALL_THROW(hipMemsetAsync(s_.b_zero, 0, malloc_size, Stream(context))); - } -- -- if (!s_.cached_benchmark_fwd_results.contains(x_dims_miopen)) { -- miopenConvAlgoPerf_t perf; -- int algo_count = 1; -- const ROCMExecutionProvider* rocm_ep = static_cast(this->Info().GetExecutionProvider()); -- static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT; -- size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId()) -- : AlgoSearchWorkspaceSize; -- IAllocatorUniquePtr algo_search_workspace = GetTransientScratchBuffer(max_ws_size); -- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm( -- GetMiopenHandle(context), -- s_.x_tensor, -- s_.x_data, -- s_.w_desc, -- s_.w_data, -- s_.conv_desc, -- s_.y_tensor, -- s_.y_data, -- 1, // requestedAlgoCount -- &algo_count, // returnedAlgoCount -- &perf, -- algo_search_workspace.get(), -- max_ws_size, -- false)); // Do not do exhaustive algo search. -- s_.cached_benchmark_fwd_results.insert(x_dims_miopen, {perf.fwd_algo, perf.memory}); -- } -- const auto& perf = s_.cached_benchmark_fwd_results.at(x_dims_miopen); -- s_.fwd_algo = perf.fwd_algo; -- s_.workspace_bytes = perf.memory; - } else { - // set Y - s_.Y = context->Output(0, TensorShape(s_.y_dims)); -@@ -319,6 +289,31 @@ Status Conv::UpdateState(OpKernelContext* context, bool bias_expected) - s_.y_data = reinterpret_cast(s_.Y->MutableData()); - } - } -+ -+ miopenConvAlgoPerf_t perf; -+ int algo_count = 1; -+ const ROCMExecutionProvider* rocm_ep = static_cast(this->Info().GetExecutionProvider()); -+ static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT; -+ size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId()) -+ : AlgoSearchWorkspaceSize; -+ IAllocatorUniquePtr algo_search_workspace = GetTransientScratchBuffer(max_ws_size); -+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm( -+ GetMiopenHandle(context), -+ s_.x_tensor, -+ s_.x_data, -+ s_.w_desc, -+ s_.w_data, -+ s_.conv_desc, -+ s_.y_tensor, -+ s_.y_data, -+ 1, // requestedAlgoCount -+ &algo_count, // returnedAlgoCount -+ &perf, -+ algo_search_workspace.get(), -+ max_ws_size, -+ false)); // Do not do exhaustive algo search. -+ s_.fwd_algo = perf.fwd_algo; -+ s_.workspace_bytes = perf.memory; - return Status::OK(); - } - -diff --git a/onnxruntime/core/providers/rocm/nn/conv.h b/onnxruntime/core/providers/rocm/nn/conv.h -index bc9846203e..d54218f258 100644 ---- a/onnxruntime/core/providers/rocm/nn/conv.h -+++ b/onnxruntime/core/providers/rocm/nn/conv.h -@@ -108,9 +108,6 @@ class lru_unordered_map { - list_type lru_list_; - }; - --// cached miopen descriptors --constexpr size_t MAX_CACHED_ALGO_PERF_RESULTS = 10000; -- - template - struct MiopenConvState { - // if x/w dims changed, update algo and miopenTensors -@@ -148,9 +145,6 @@ struct MiopenConvState { - decltype(AlgoPerfType().memory) memory; - }; - -- lru_unordered_map cached_benchmark_fwd_results{MAX_CACHED_ALGO_PERF_RESULTS}; -- lru_unordered_map cached_benchmark_bwd_results{MAX_CACHED_ALGO_PERF_RESULTS}; -- - // Some properties needed to support asymmetric padded Conv nodes - bool post_slicing_required; - TensorShapeVector slice_starts; -diff --git a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc -index 7447113fdf..a662e35b2e 100644 ---- a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc -+++ b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc -@@ -76,7 +76,6 @@ Status ConvTranspose::DoConvTranspose(OpKernelContext* context, bool dy - - if (w_dims_changed) { - s_.last_w_dims = gsl::make_span(w_dims); -- s_.cached_benchmark_bwd_results.clear(); - } - - ConvTransposeAttributes::Prepare p; -@@ -126,35 +125,29 @@ Status ConvTranspose::DoConvTranspose(OpKernelContext* context, bool dy - } - - y_data = reinterpret_cast(p.Y->MutableData()); -- -- if (!s_.cached_benchmark_bwd_results.contains(x_dims)) { -- IAllocatorUniquePtr algo_search_workspace = GetScratchBuffer(AlgoSearchWorkspaceSize, context->GetComputeStream()); -- -- miopenConvAlgoPerf_t perf; -- int algo_count = 1; -- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm( -- GetMiopenHandle(context), -- s_.x_tensor, -- x_data, -- s_.w_desc, -- w_data, -- s_.conv_desc, -- s_.y_tensor, -- y_data, -- 1, -- &algo_count, -- &perf, -- algo_search_workspace.get(), -- AlgoSearchWorkspaceSize, -- false)); -- s_.cached_benchmark_bwd_results.insert(x_dims, {perf.bwd_data_algo, perf.memory}); -- } -- -- const auto& perf = s_.cached_benchmark_bwd_results.at(x_dims); -- s_.bwd_data_algo = perf.bwd_data_algo; -- s_.workspace_bytes = perf.memory; - } - -+ IAllocatorUniquePtr algo_search_workspace = GetScratchBuffer(AlgoSearchWorkspaceSize, context->GetComputeStream()); -+ miopenConvAlgoPerf_t perf; -+ int algo_count = 1; -+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm( -+ GetMiopenHandle(context), -+ s_.x_tensor, -+ x_data, -+ s_.w_desc, -+ w_data, -+ s_.conv_desc, -+ s_.y_tensor, -+ y_data, -+ 1, -+ &algo_count, -+ &perf, -+ algo_search_workspace.get(), -+ AlgoSearchWorkspaceSize, -+ false)); -+ s_.bwd_data_algo = perf.bwd_data_algo; -+ s_.workspace_bytes = perf.memory; -+ - // The following block will be executed in case there has been no change in the shapes of the - // input and the filter compared to the previous run - if (!y_data) { diff --git a/machine-learning/patches/0002-install-system-deps.patch b/machine-learning/patches/0002-install-system-deps.patch deleted file mode 100644 index 6e76b1e243..0000000000 --- a/machine-learning/patches/0002-install-system-deps.patch +++ /dev/null @@ -1,33 +0,0 @@ -diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh -index bbb672a99e..0dc652fbda 100644 ---- a/dockerfiles/scripts/install_common_deps.sh -+++ b/dockerfiles/scripts/install_common_deps.sh -@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \ - curl \ - libcurl4-openssl-dev \ - libssl-dev \ -- python3-dev -+ python3-dev \ -+ ccache - - # Dependencies: conda --wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda -+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda - rm ~/miniconda.sh - /opt/miniconda/bin/conda clean -ya - --pip install numpy --pip install packaging --pip install "wheel>=0.35.1" -+# Dependencies: venv and packages -+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv -+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip -+/opt/rocm-venv/bin/pip install --no-cache-dir \ -+ "numpy==2.3.4" \ -+ "packaging==25.0" \ -+ "wheel==0.45.1" \ -+ "setuptools==80.9.0" -+ - rm -rf /opt/miniconda/pkgs - - # Dependencies: cmake diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index c43d0df2cc..e3ce9c002f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -49,10 +49,10 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }] [project.optional-dependencies] cpu = ["onnxruntime>=1.23.2,<2"] cuda = ["onnxruntime-gpu>=1.23.2,<2"] -openvino = ["onnxruntime-openvino>=1.23.0,<2"] +openvino = ["onnxruntime-openvino>=1.24.1,<2"] armnn = ["onnxruntime>=1.23.2,<2"] rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"] -rocm = [] +rocm = ["onnxruntime-migraphx>=1.23.2,<2"] [tool.uv] compile-bytecode = true diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index eb8706fc19..0182c57c67 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -18,7 +18,7 @@ from PIL import Image from pytest import MonkeyPatch from pytest_mock import MockerFixture -from immich_ml.config import Settings, settings +from immich_ml.config import MaxBatchSize, Settings, settings from immich_ml.main import load, preload_models from immich_ml.models.base import InferenceModel from immich_ml.models.cache import ModelCache @@ -26,6 +26,9 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn from immich_ml.models.clip.visual import OpenClipVisualEncoder from immich_ml.models.facial_recognition.detection import FaceDetector from immich_ml.models.facial_recognition.recognition import FaceRecognizer +from immich_ml.models.ocr.detection import TextDetector +from immich_ml.models.ocr.recognition import TextRecognizer +from immich_ml.models.ocr.schemas import OcrOptions from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType from immich_ml.sessions.ann import AnnSession from immich_ml.sessions.ort import OrtSession @@ -179,7 +182,7 @@ class TestOrtSession: OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"] CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"] TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"] - ROCM_EP = ["ROCMExecutionProvider", "CPUExecutionProvider"] + ROCM_EP = ["MIGraphXExecutionProvider", "CPUExecutionProvider"] COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"] @pytest.mark.providers(CPU_EP) @@ -201,13 +204,6 @@ class TestOrtSession: assert session.providers == self.OV_EP - @pytest.mark.ov_device_ids(["CPU"]) - @pytest.mark.providers(OV_EP) - def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None: - session = OrtSession("ViT-B-32__openai") - - assert session.providers == self.CPU_EP - @pytest.mark.providers(CUDA_EP_OUT_OF_ORDER) def test_sets_providers_in_correct_order(self, providers: list[str]) -> None: session = OrtSession("ViT-B-32__openai") @@ -253,7 +249,8 @@ class TestOrtSession: {"arena_extend_strategy": "kSameAsRequested"}, ] - def test_sets_provider_options_for_openvino(self) -> None: + @pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"]) + def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None: model_path = "/cache/ViT-B-32__openai/textual/model.onnx" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" @@ -267,7 +264,8 @@ class TestOrtSession: } ] - def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None: + @pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"]) + def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None: model_path = "/cache/ViT-B-32__openai/textual/model.onnx" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16) @@ -282,6 +280,19 @@ class TestOrtSession: } ] + @pytest.mark.ov_device_ids(["CPU"]) + def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None: + model_path = "/cache/ViT-B-32__openai/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.provider_options == [ + { + "device_type": "CPU", + "precision": "FP32", + "cache_dir": "/cache/ViT-B-32__openai/openvino", + } + ] + def test_sets_provider_options_for_cuda(self) -> None: os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" @@ -289,12 +300,38 @@ class TestOrtSession: assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}] - def test_sets_provider_options_for_rocm(self) -> None: + def test_sets_provider_options_for_rocm(self, mocker: MockerFixture) -> None: + model_path = "/cache/ViT-B-32__openai/textual/model.onnx" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir") - session = OrtSession("ViT-B-32__openai", providers=["ROCMExecutionProvider"]) + session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"]) - assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}] + assert session.provider_options == [ + { + "device_id": "1", + "migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx", + "migraphx_fp16_enable": "0", + } + ] + mkdir.assert_called_once_with(parents=True, exist_ok=True) + + def test_sets_rocm_to_fp16_if_enabled(self, path: mock.Mock, mocker: MockerFixture) -> None: + model_path = "/cache/ViT-B-32__openai/textual/model.onnx" + os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + mocker.patch.object(settings, "rocm_precision", ModelPrecision.FP16) + mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir") + + session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"]) + + assert session.provider_options == [ + { + "device_id": "1", + "migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx", + "migraphx_fp16_enable": "1", + } + ] + mkdir.assert_called_once_with(parents=True, exist_ok=True) def test_sets_provider_options_kwarg(self) -> None: session = OrtSession( @@ -312,6 +349,23 @@ class TestOrtSession: assert session.sess_options.inter_op_num_threads == 1 assert session.sess_options.intra_op_num_threads == 2 + @pytest.mark.ov_device_ids(["CPU"]) + def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None: + model_path = "/cache/ViT-B-32__openai/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL + assert session.sess_options.inter_op_num_threads == 0 + assert session.sess_options.intra_op_num_threads == 0 + + @pytest.mark.ov_device_ids(["GPU.0", "CPU"]) + def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None: + model_path = "/cache/ViT-B-32__openai/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.sess_options.inter_op_num_threads == 0 + assert session.sess_options.intra_op_num_threads == 0 + def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None: session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) @@ -829,6 +883,78 @@ class TestFaceRecognition: onnx.load.assert_not_called() onnx.save.assert_not_called() + def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None: + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2)) + + recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache") + + assert recognizer.batch_size == 2 + + def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None: + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2)) + + recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache") + + assert recognizer.batch_size is None + + +class TestOcr: + def test_set_det_min_score(self, path: mock.Mock) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + + text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache") + + assert text_detector.postprocess.box_thresh == 0.8 + + def test_set_rec_min_score(self, path: mock.Mock) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache") + + assert text_recognizer.min_score == 0.8 + + def test_set_rec_set_default_max_batch_size( + self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + ) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + mocker.patch("immich_ml.models.base.InferenceModel.download") + rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer") + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache") + text_recognizer.load() + + rapid_recognizer.assert_called_once_with( + OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320)) + ) + + def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + mocker.patch("immich_ml.models.base.InferenceModel.download") + rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer") + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4)) + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache") + text_recognizer.load() + + rapid_recognizer.assert_called_once_with( + OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320)) + ) + + def test_ignore_other_custom_max_batch_size( + self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + ) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + mocker.patch("immich_ml.models.base.InferenceModel.download") + rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer") + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3)) + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache") + text_recognizer.load() + + rapid_recognizer.assert_called_once_with( + OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320)) + ) + @pytest.mark.asyncio class TestCache: diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 1540d391e4..5f87a59fa6 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -262,18 +262,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "coloredlogs" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, -] - [[package]] name = "colorlog" version = "6.9.0" @@ -886,18 +874,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, ] -[[package]] -name = "humanfriendly" -version = "10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -960,6 +936,9 @@ rknn = [ { name = "onnxruntime" }, { name = "rknn-toolkit-lite2" }, ] +rocm = [ + { name = "onnxruntime-migraphx" }, +] [package.dev-dependencies] dev = [ @@ -1013,7 +992,8 @@ requires-dist = [ { name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" }, - { name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" }, + { name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" }, + { name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" }, { name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" }, { name = "orjson", specifier = ">=3.9.5" }, { name = "pillow", specifier = ">=12.1.1,<12.2" }, @@ -1429,32 +1409,55 @@ wheels = [ [[package]] name = "msgpack" -version = "1.0.7" +version = "1.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/d5/5662032db1571110b5b51647aed4b56dfbd01bfae789fa566a2be1f385d1/msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", size = 166311, upload-time = "2023-09-28T13:20:36.726Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/b3/309de40dc7406b7f3492332c5ee2b492a593c2a9bb97ea48ebf2f5279999/msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", size = 305096, upload-time = "2023-09-28T13:18:49.678Z" }, - { url = "https://files.pythonhosted.org/packages/15/56/a677cd761a2cefb2e3ffe7e684633294dccb161d78e8ea6da9277e45b4a2/msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", size = 235210, upload-time = "2023-09-28T13:18:51.039Z" }, - { url = "https://files.pythonhosted.org/packages/f5/4e/1ab4a982cbd90f988e49f849fc1212f2c04a59870c59daabf8950617e2aa/msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", size = 231952, upload-time = "2023-09-28T13:18:52.871Z" }, - { url = "https://files.pythonhosted.org/packages/6d/74/bd02044eb628c7361ad2bd8c1a6147af5c6c2bbceb77b3b1da20f4a8a9c5/msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", size = 549511, upload-time = "2023-09-28T13:18:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/df/09/dee50913ba5cc047f7fd7162f09453a676e7935c84b3bf3a398e12108677/msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", size = 557980, upload-time = "2023-09-28T13:18:56.058Z" }, - { url = "https://files.pythonhosted.org/packages/26/a5/78a7d87f5f8ffe4c32167afa15d4957db649bab4822f909d8d765339bbab/msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", size = 545547, upload-time = "2023-09-28T13:18:57.396Z" }, - { url = "https://files.pythonhosted.org/packages/d4/53/698c10913947f97f6fe7faad86a34e6aa1b66cea2df6f99105856bd346d9/msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", size = 554669, upload-time = "2023-09-28T13:18:58.957Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3f/9730c6cb574b15d349b80cd8523a7df4b82058528339f952ea1c32ac8a10/msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", size = 583353, upload-time = "2023-09-28T13:19:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/4c/bc/dc184d943692671149848438fb3bed3a3de288ce7998cb91bc98f40f201b/msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", size = 557455, upload-time = "2023-09-28T13:19:03.201Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7b/1bc69d4a56c8d2f4f2dfbe4722d40344af9a85b6fb3b09cfb350ba6a42f6/msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", size = 216367, upload-time = "2023-09-28T13:19:04.554Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/c8dd23050eefa3d9b9c5b8329ed3308c2f2f80f65825e9ea4b7fa621cdab/msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", size = 222860, upload-time = "2023-09-28T13:19:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d7/47/20dff6b4512cf3575550c8801bc53fe7d540f4efef9c5c37af51760fcdcf/msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", size = 305759, upload-time = "2023-09-28T13:19:08.148Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8a/34f1726d2c9feccec3d946776e9bce8f20ae09d8b91899fc20b296c942af/msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", size = 235330, upload-time = "2023-09-28T13:19:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f6/e64c72577d6953789c3cb051b059a4b56317056b3c65013952338ed8a34e/msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", size = 232537, upload-time = "2023-09-28T13:19:10.898Z" }, - { url = "https://files.pythonhosted.org/packages/89/75/1ed3a96e12941873fd957e016cc40c0c178861a872bd45e75b9a188eb422/msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", size = 546561, upload-time = "2023-09-28T13:19:12.779Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0a/c6a1390f9c6a31da0fecbbfdb86b1cb39ad302d9e24f9cca3d9e14c364f0/msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", size = 559009, upload-time = "2023-09-28T13:19:14.373Z" }, - { url = "https://files.pythonhosted.org/packages/a5/74/99f6077754665613ea1f37b3d91c10129f6976b7721ab4d0973023808e5a/msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", size = 543882, upload-time = "2023-09-28T13:19:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7e/dc0dc8de2bf27743b31691149258f9b1bd4bf3c44c105df3df9b97081cd1/msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", size = 546949, upload-time = "2023-09-28T13:19:18.114Z" }, - { url = "https://files.pythonhosted.org/packages/78/61/91bae9474def032f6c333d62889bbeda9e1554c6b123375ceeb1767efd78/msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", size = 579836, upload-time = "2023-09-28T13:19:19.729Z" }, - { url = "https://files.pythonhosted.org/packages/5d/4d/d98592099d4f18945f89cf3e634dc0cb128bb33b1b93f85a84173d35e181/msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", size = 556587, upload-time = "2023-09-28T13:19:21.666Z" }, - { url = "https://files.pythonhosted.org/packages/5e/44/6556ffe169bf2c0e974e2ea25fb82a7e55ebcf52a81b03a5e01820de5f84/msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", size = 216509, upload-time = "2023-09-28T13:19:23.161Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c1/63903f30d51d165e132e5221a2a4a1bbfab7508b68131c871d70bffac78a/msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", size = 223287, upload-time = "2023-09-28T13:19:25.097Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] @@ -1702,11 +1705,10 @@ wheels = [ ] [[package]] -name = "onnxruntime-openvino" -version = "1.23.0" +name = "onnxruntime-migraphx" +version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs" }, { name = "flatbuffers" }, { name = "numpy" }, { name = "packaging" }, @@ -1714,12 +1716,30 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/10/adcd4ac68ffc8dee003553125ef5c091be822e2d7c1077d0bb85690baa9c/onnxruntime_openvino-1.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:91938837e6e92e30c63d12fad68a8a4959c40d2eade2bd60f38bdd5b6392f8d3", size = 70481480, upload-time = "2025-10-14T15:19:45.882Z" }, - { url = "https://files.pythonhosted.org/packages/97/95/25f28d6fecf300aa0af393e96af9e00cc676e5dab650ab84f2122610df50/onnxruntime_openvino-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f05d2d6a804fb70d3f4329d777ac62439773dcc2df827dd5f42644b10bf1fea", size = 13117353, upload-time = "2025-10-14T15:19:49.014Z" }, - { url = "https://files.pythonhosted.org/packages/42/0c/8d97419dfeedf419c5fe5293f3dbc59284855a63ad22e71f46c0010c9dc4/onnxruntime_openvino-1.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b963ea19bf9856f3d6b2f719d451f2eeae482a8f69c729906465aa4f27f4d39c", size = 70483359, upload-time = "2025-10-14T15:19:52.88Z" }, - { url = "https://files.pythonhosted.org/packages/29/30/ff6111b16ffb4187c462824aa4e95acc20fdd90f856d44a339d56c6dacd6/onnxruntime_openvino-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:937e52657f94c56990a6e5bd4c3705bd6e970834c7c94e23d300dde6848f2889", size = 13117933, upload-time = "2025-10-14T15:19:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/e42f618a8ec5fcf825fed4fdc8125f7105256cc6020b84567ecb88d5e2b7/onnxruntime_openvino-1.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2e93b9a8323e196b7433866054a59260f2206ab6fb0e7223dda91da71f1db8c5", size = 70483088, upload-time = "2025-10-14T15:20:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f9/a531dc497dc113dc14df9a9de5aacb1676cadebc3ec6cc7cd3ca65cb3db0/onnxruntime_openvino-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:0ebbf70929de4ce269371cb255536bbedef588932d744da0b40e66c38a620f35", size = 13118206, upload-time = "2025-10-14T15:20:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/dd/da/ca7ebc1a8d1193c97ceb9a05fad50f675eb955dc51beb7eb9ba89c8e7db0/onnxruntime_migraphx-1.24.2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:a2b434fb8880cac2b268950bdf279f33741d29c1f1c5461d27af835e8e288043", size = 20339710, upload-time = "2026-02-21T07:25:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/8c83ec45a9365b4256495ca55eea30da7f03b02177b6da423c7da1ff5f6a/onnxruntime_migraphx-1.24.2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ec814818da952bda3062e26f56c88bb713c00491ef91f86716c8d7346f9bc31b", size = 20341883, upload-time = "2026-02-21T07:25:17.86Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/4776ac68dbc46ca02c9a14cc9e5c496017f47a18cedf606cc38f4911b96a/onnxruntime_migraphx-1.24.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:20e497538362170af639b03a40249d7ed61b873ac354f20d732b90252206e320", size = 20342422, upload-time = "2026-02-21T07:25:22.526Z" }, + { url = "https://files.pythonhosted.org/packages/76/44/db9035204a3363f9c0a4822c68e9a7520c13ef8d261f96b89b1375106dab/onnxruntime_migraphx-1.24.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:9d7f1b1a2b9651143a2080b4f42ee99eead02023de1855d1b8a02199a9c179aa", size = 20343783, upload-time = "2026-02-21T07:25:29.155Z" }, +] + +[[package]] +name = "onnxruntime-openvino" +version = "1.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/16/69ca742f0b65c40d4de3ff44bb6abc23c47b23e932bc901116176ae69922/onnxruntime_openvino-1.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3007c803634cc69c6d52af1dea7ce729d9bb62b9a11070fd2f959119199007a8", size = 84430935, upload-time = "2026-02-26T13:44:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/619bb416bbfc40aebdd493fd6800d2637359294fe683d8a6bae3ff8d869a/onnxruntime_openvino-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:8042698232bf67f1f6b219c2b07728d7ae7ddff17d8524588de3675480609aef", size = 13655357, upload-time = "2026-02-26T13:44:35.555Z" }, + { url = "https://files.pythonhosted.org/packages/50/cf/17ba72de2df0fcba349937d2788f154397bbc2d1a2d67772a97e26f6bc5f/onnxruntime_openvino-1.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d617fac2f59a6ab5ea59a788c3e1592240a129642519aaeaa774761dfe35150e", size = 84433207, upload-time = "2026-02-26T13:44:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/59/37/d301f2c68b19a9485ed5db3047e0fb52478f3e73eb08c7d2a7c61be7cc1c/onnxruntime_openvino-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:f186335a9c9b255633275290da7521d3d4d14c7773fee3127bfa040234d3fa5a", size = 13658075, upload-time = "2026-02-26T13:44:44.905Z" }, + { url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" }, ] [[package]] @@ -2159,15 +2179,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" }, ] -[[package]] -name = "pyreadline3" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/86/3d61a61f36a0067874a00cb4dceb9028d34b6060e47828f7fc86fb9f7ee9/pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae", size = 86465, upload-time = "2022-01-24T20:05:11.66Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb", size = 95203, upload-time = "2022-01-24T20:05:10.442Z" }, -] - [[package]] name = "pytest" version = "9.0.2" diff --git a/mise.toml b/mise.toml index a87b1c3a29..0ec32de20c 100644 --- a/mise.toml +++ b/mise.toml @@ -16,9 +16,9 @@ config_roots = [ [tools] node = "24.13.1" flutter = "3.35.7" -pnpm = "10.30.0" -terragrunt = "0.98.0" -opentofu = "1.11.4" +pnpm = "10.30.3" +terragrunt = "0.99.4" +opentofu = "1.11.5" java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 275a38a970..895203fb98 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -9,7 +9,7 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml -formatter: +formatter: page_width: 120 linter: @@ -33,6 +33,7 @@ linter: require_trailing_commas: true unrelated_type_equality_checks: true prefer_const_constructors: true + always_use_package_imports: true # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 4999f9a7f9..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 } @@ -81,6 +82,7 @@ android { release { signingConfig signingConfigs.release + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } namespace 'app.alextran.immich' @@ -111,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/proguard-rules.pro b/mobile/android/app/proguard-rules.pro index 898caee06c..af43ae23c2 100644 --- a/mobile/android/app/proguard-rules.pro +++ b/mobile/android/app/proguard-rules.pro @@ -36,4 +36,12 @@ ##---------------End: proguard configuration for Gson ---------- # Keep all widget model classes and their fields for Gson --keep class app.alextran.immich.widget.model.** { *; } \ No newline at end of file +-keep class app.alextran.immich.widget.model.** { *; } + +##---------------Begin: proguard configuration for ok_http JNI ---------- +# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI +# string-based reflection (JClass.forName), which R8 cannot trace. +-keep class okhttp3.** { *; } +-keep class okio.** { *; } +-keep class com.example.ok_http.** { *; } +##---------------End: proguard configuration for ok_http JNI ---------- diff --git a/mobile/android/app/src/main/cpp/native_buffer.c b/mobile/android/app/src/main/cpp/native_buffer.c index bcc9d5c7c8..bed1045382 100644 --- a/mobile/android/app/src/main/cpp/native_buffer.c +++ b/mobile/android/app/src/main/cpp/native_buffer.c @@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy( memcpy((void *) destAddress, (char *) src + offset, length); } } + +/** + * Creates a JNI global reference to the given object and returns its address. + * The caller is responsible for deleting the global reference when it's no longer needed. + */ +JNIEXPORT jlong JNICALL +Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) { + if (obj == NULL) { + return 0; + } + + jobject globalRef = (*env)->NewGlobalRef(env, obj); + return (jlong) globalRef; +} 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/NativeBuffer.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt index a9011f3047..74f0241850 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt @@ -23,6 +23,9 @@ object NativeBuffer { @JvmStatic external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) + + @JvmStatic + external fun createGlobalRef(obj: Any): Long } class NativeByteBuffer(initialCapacity: Int) { 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 ee92c2120e..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 @@ -1,18 +1,43 @@ 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.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient +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 @@ -20,14 +45,31 @@ import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509KeyManager import javax.net.ssl.X509TrustManager -const val CERT_ALIAS = "client_cert" const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" +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_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 @@ -36,22 +78,93 @@ object HttpClientManager { private val clientChangedListeners = mutableListOf<() -> Unit>() private lateinit var client: OkHttpClient + 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) } - val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS) + var keyChainAlias: String? = null + private set + + 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) { if (initialized) return synchronized(this) { if (initialized) return + appContext = context.applicationContext + 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 map = Json.decodeFromString>(savedHeaders) + val builder = Headers.Builder() + 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 } } + fun setKeyChainAlias(alias: String) { + synchronized(this) { + val wasMtls = isMtls + keyChainAlias = alias + prefs.edit { putString(PREFS_CERT_ALIAS, alias) } + + if (wasMtls != isMtls) { + clientChangedListeners.forEach { it() } + } + } + } + fun setKeyEntry(clientData: ByteArray, password: CharArray) { synchronized(this) { val wasMtls = isMtls @@ -63,7 +176,7 @@ object HttpClientManager { val key = tmpKeyStore.getKey(tmpAlias, password) val chain = tmpKeyStore.getCertificateChain(tmpAlias) - if (wasMtls) { + if (keyStore.containsAlias(CERT_ALIAS)) { keyStore.deleteEntry(CERT_ALIAS) } keyStore.setKeyEntry(CERT_ALIAS, key, null, chain) @@ -75,24 +188,130 @@ object HttpClientManager { fun deleteKeyEntry() { synchronized(this) { - if (!isMtls) { - return + val wasMtls = isMtls + + if (keyChainAlias != null) { + keyChainAlias = null + prefs.edit { remove(PREFS_CERT_ALIAS) } } keyStore.deleteEntry(CERT_ALIAS) - clientChangedListeners.forEach { it() } + + if (wasMtls) { + clientChangedListeners.forEach { it() } + } } } + private var clientGlobalRef: Long = 0L + @JvmStatic fun getClient(): OkHttpClient { return client } + fun getClientPointer(): Long { + if (clientGlobalRef == 0L) { + clientGlobalRef = NativeBuffer.createGlobalRef(client) + } + return clientGlobalRef + } + fun addClientChangedListener(listener: () -> Unit) { synchronized(this) { clientChangedListeners.add(listener) } } + 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 urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null) + + headers = newHeaders + 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, @@ -109,8 +328,17 @@ object HttpClientManager { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) return OkHttpClient.Builder() - .addInterceptor { chain -> - chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build()) + .cookieJar(cookieJar) + .addInterceptor { + val request = it.request() + val builder = request.newBuilder() + builder.header("User-Agent", USER_AGENT) + headers.forEach { (key, value) -> builder.header(key, value) } + val url = request.url + if (url.username.isNotEmpty()) { + builder.header("Authorization", Credentials.basic(url.username, url.password)) + } + it.proceed(builder.build()) } .connectionPool(connectionPool) .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) @@ -119,23 +347,39 @@ object HttpClientManager { .build() } - // Reads from the key store rather than taking a snapshot at initialization time + /** + * Resolves client certificates dynamically at TLS handshake time. + * Checks the system KeyChain alias first, then falls back to the app's private KeyStore. + */ private class DynamicKeyManager : X509KeyManager { - override fun getClientAliases(keyType: String, issuers: Array?): Array? = - if (isMtls) arrayOf(CERT_ALIAS) else null + override fun getClientAliases(keyType: String, issuers: Array?): Array? { + val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null + return arrayOf(alias) + } override fun chooseClientAlias( keyTypes: Array, issuers: Array?, socket: Socket? - ): String? = - if (isMtls) CERT_ALIAS else null + ): String? { + keyChainAlias?.let { return it } + if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS + return null + } - override fun getCertificateChain(alias: String): Array? = - keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + override fun getCertificateChain(alias: String): Array? { + if (alias == keyChainAlias) { + return KeyChain.getCertificateChain(appContext, alias) + } + return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + } - override fun getPrivateKey(alias: String): PrivateKey? = - keyStore.getKey(alias, null) as? PrivateKey + override fun getPrivateKey(alias: String): PrivateKey? { + if (alias == keyChainAlias) { + return KeyChain.getPrivateKey(appContext, alias) + } + return keyStore.getKey(alias, null) as? PrivateKey + } override fun getServerAliases(keyType: String, issuers: Array?): Array? = null @@ -146,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 1e7156a147..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 @@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface NetworkApi { fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) - fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) + fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) fun removeCertificate(callback: (Result) -> Unit) + fun hasCertificate(): Boolean + fun getClientPointer(): Long + fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) companion object { /** The codec used by NetworkApi. */ @@ -217,13 +220,12 @@ interface NetworkApi { channel.setMessageHandler { message, reply -> val args = message as List val promptTextArg = args[0] as ClientCertPrompt - api.selectCertificate(promptTextArg) { result: Result -> + api.selectCertificate(promptTextArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(NetworkPigeonUtils.wrapError(error)) } else { - val data = result.getOrNull() - reply.reply(NetworkPigeonUtils.wrapResult(data)) + reply.reply(NetworkPigeonUtils.wrapResult(null)) } } } @@ -248,6 +250,56 @@ interface NetworkApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.hasCertificate()) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getClientPointer()) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + 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, tokenArg) + listOf(null) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } 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 4f25896b2f..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 @@ -2,20 +2,9 @@ package app.alextran.immich.core import android.app.Activity import android.content.Context -import android.net.Uri import android.os.OperationCanceledException -import android.text.InputType -import android.view.ContextThemeWrapper -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.FrameLayout -import android.widget.LinearLayout -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +import android.security.KeyChain +import app.alextran.immich.NativeBuffer import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -24,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { private var networkApi: NetworkApiImpl? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - networkApi = NetworkApiImpl(binding.applicationContext) + networkApi = NetworkApiImpl() NetworkApi.setUp(binding.binaryMessenger, networkApi) } @@ -34,48 +23,24 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - networkApi?.onAttachedToActivity(binding) + networkApi?.activity = binding.activity } override fun onDetachedFromActivityForConfigChanges() { - networkApi?.onDetachedFromActivityForConfigChanges() + networkApi?.activity = null } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - networkApi?.onReattachedToActivityForConfigChanges(binding) + networkApi?.activity = binding.activity } override fun onDetachedFromActivity() { - networkApi?.onDetachedFromActivity() + networkApi?.activity = null } } -private class NetworkApiImpl(private val context: Context) : NetworkApi { - private var activity: Activity? = null - private var pendingCallback: ((Result) -> Unit)? = null - private var filePicker: ActivityResultLauncher>? = null - private var promptText: ClientCertPrompt? = null - - fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - (binding.activity as? ComponentActivity)?.let { componentActivity -> - filePicker = componentActivity.registerForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) } - } - } - - fun onDetachedFromActivityForConfigChanges() { - activity = null - } - - fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - } - - fun onDetachedFromActivity() { - activity = null - } +private class NetworkApiImpl : NetworkApi { + var activity: Activity? = null override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { try { @@ -86,11 +51,19 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { } } - override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) { - val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) - pendingCallback = callback - this.promptText = promptText - picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file")) + override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) { + val currentActivity = activity + ?: return callback(Result.failure(IllegalStateException("No activity"))) + + val onAlias = { alias: String? -> + if (alias != null) { + HttpClientManager.setKeyChainAlias(alias) + callback(Result.success(Unit)) + } else { + callback(Result.failure(OperationCanceledException())) + } + } + KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null) } override fun removeCertificate(callback: (Result) -> Unit) { @@ -98,62 +71,15 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { callback(Result.success(Unit)) } - private fun handlePickedFile(uri: Uri) { - val callback = pendingCallback ?: return - pendingCallback = null - - try { - val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IllegalStateException("Could not read file") - - val activity = activity ?: throw IllegalStateException("No activity") - promptForPassword(activity) { password -> - promptText = null - if (password == null) { - callback(Result.failure(OperationCanceledException())) - return@promptForPassword - } - try { - HttpClientManager.setKeyEntry(data, password.toCharArray()) - callback(Result.success(ClientCertData(data, password))) - } catch (e: Exception) { - callback(Result.failure(e)) - } - } - } catch (e: Exception) { - callback(Result.failure(e)) - } + override fun hasCertificate(): Boolean { + return HttpClientManager.isMtls } - private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) { - val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog) - val density = activity.resources.displayMetrics.density - val horizontalPadding = (24 * density).toInt() + override fun getClientPointer(): Long { + return HttpClientManager.getClientPointer() + } - val textInputLayout = TextInputLayout(themedContext).apply { - hint = "Password" - endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { - setMargins(horizontalPadding, 0, horizontalPadding, 0) - } - } - - val editText = TextInputEditText(textInputLayout.context).apply { - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - } - textInputLayout.addView(editText) - - val container = FrameLayout(themedContext).apply { addView(textInputLayout) } - - val text = promptText!! - MaterialAlertDialogBuilder(themedContext) - .setTitle(text.title) - .setMessage(text.message) - .setView(container) - .setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) } - .setNegativeButton(text.cancel) { _, _ -> callback(null) } - .setOnCancelListener { callback(null) } - .show() + 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/LocalImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt index 5b95daf38b..7d998c2f48 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt @@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface LocalImageApi { - fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result?>) -> Unit) + fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) fun getThumbhash(thumbhash: String, callback: (Result>) -> Unit) @@ -82,7 +82,8 @@ interface LocalImageApi { val widthArg = args[2] as Long val heightArg = args[3] as Long val isVideoArg = args[4] as Boolean - api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result?> -> + val preferEncodedArg = args[5] as Boolean + api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(LocalImagesPigeonUtils.wrapError(error)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index 64e67cbfee..3babad2e37 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -14,6 +14,7 @@ import android.util.Size import androidx.annotation.RequiresApi import app.alextran.immich.NativeBuffer import kotlin.math.* +import java.io.IOException import java.util.concurrent.Executors import com.bumptech.glide.Glide import com.bumptech.glide.Priority @@ -99,12 +100,17 @@ class LocalImagesImpl(context: Context) : LocalImageApi { width: Long, height: Long, isVideo: Boolean, + preferEncoded: Boolean, callback: (Result?>) -> Unit ) { val signal = CancellationSignal() val task = threadPool.submit { try { - getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal) + if (preferEncoded) { + getEncodedImageInternal(assetId, callback, signal) + } else { + getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal) + } } catch (e: Exception) { when (e) { is OperationCanceledException -> callback(CANCELLED) @@ -133,6 +139,35 @@ class LocalImagesImpl(context: Context) : LocalImageApi { } } + private fun getEncodedImageInternal( + assetId: String, + callback: (Result?>) -> Unit, + signal: CancellationSignal + ) { + signal.throwIfCanceled() + val id = assetId.toLong() + val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id) + + signal.throwIfCanceled() + val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IOException("Could not read image data for $assetId") + + signal.throwIfCanceled() + val pointer = NativeBuffer.allocate(bytes.size) + try { + val buffer = NativeBuffer.wrap(pointer, bytes.size) + buffer.put(bytes) + signal.throwIfCanceled() + callback(Result.success(mapOf( + "pointer" to pointer, + "length" to bytes.size.toLong() + ))) + } catch (e: Exception) { + NativeBuffer.free(pointer) + throw e + } + } + private fun getThumbnailBufferInternal( assetId: String, width: Long, diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt index 0e3cf19657..bef6418904 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface RemoteImageApi { - fun requestImage(url: String, headers: Map, requestId: Long, callback: (Result?>) -> Unit) + fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) fun clearCache(callback: (Result) -> Unit) @@ -66,9 +66,9 @@ interface RemoteImageApi { channel.setMessageHandler { message, reply -> val args = message as List val urlArg = args[0] as String - val headersArg = args[1] as Map - val requestIdArg = args[2] as Long - api.requestImage(urlArg, headersArg, requestIdArg) { result: Result?> -> + val requestIdArg = args[1] as Long + val preferEncodedArg = args[2] as Boolean + api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(RemoteImagesPigeonUtils.wrapError(error)) 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 04a181cd6e..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,7 +14,6 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest import org.chromium.net.UrlResponseInfo @@ -29,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) @@ -49,8 +43,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { override fun requestImage( url: String, - headers: Map, requestId: Long, + @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android callback: (Result?>) -> Unit ) { val signal = CancellationSignal() @@ -58,7 +52,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { ImageFetcherManager.fetch( url, - headers, signal, onSuccess = { buffer -> requestMap.remove(requestId) @@ -100,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 @@ -109,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) @@ -119,12 +110,11 @@ private object ImageFetcherManager { fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, ) { - fetcher.fetch(url, headers, signal, onSuccess, onFailure) + fetcher.fetch(url, signal, onSuccess, onFailure) } fun clearCache(onCleared: (Result) -> Unit) { @@ -143,7 +133,7 @@ private object ImageFetcherManager { return if (HttpClientManager.isMtls) { OkHttpImageFetcher.create(cacheDir) } else { - CronetImageFetcher(appContext, cacheDir) + CronetImageFetcher() } } } @@ -151,7 +141,6 @@ private object ImageFetcherManager { private sealed interface ImageFetcher { fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -162,23 +151,14 @@ 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, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -192,24 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } val callback = FetchCallback(onSuccess, onFailure, ::onComplete) - val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) - headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + 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-- @@ -232,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) } @@ -371,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) @@ -390,7 +359,6 @@ private class OkHttpImageFetcher private constructor( override fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -403,7 +371,6 @@ private class OkHttpImageFetcher private constructor( } val requestBuilder = Request.Builder().url(url) - headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } val call = client.newCall(requestBuilder.build()) signal.setOnCancelListener(call::cancel) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt index b59f47a1d6..29c197c2b6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -78,6 +78,21 @@ class FlutterError ( val details: Any? = null ) : Throwable() +enum class PlatformAssetPlaybackStyle(val raw: Int) { + UNKNOWN(0), + IMAGE(1), + VIDEO(2), + IMAGE_ANIMATED(3), + LIVE_PHOTO(4), + VIDEO_LOOPING(5); + + companion object { + fun ofRaw(raw: Int): PlatformAssetPlaybackStyle? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** Generated class from Pigeon that represents data sent in messages. */ data class PlatformAsset ( val id: String, @@ -92,7 +107,8 @@ data class PlatformAsset ( val isFavorite: Boolean, val adjustmentTime: Long? = null, val latitude: Double? = null, - val longitude: Double? = null + val longitude: Double? = null, + val playbackStyle: PlatformAssetPlaybackStyle ) { companion object { @@ -110,7 +126,8 @@ data class PlatformAsset ( val adjustmentTime = pigeonVar_list[10] as Long? val latitude = pigeonVar_list[11] as Double? val longitude = pigeonVar_list[12] as Double? - return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude) + val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle + return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle) } } fun toList(): List { @@ -128,6 +145,7 @@ data class PlatformAsset ( adjustmentTime, latitude, longitude, + playbackStyle, ) } override fun equals(other: Any?): Boolean { @@ -290,26 +308,31 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { - return (readValue(buffer) as? List)?.let { - PlatformAsset.fromList(it) + return (readValue(buffer) as Long?)?.let { + PlatformAssetPlaybackStyle.ofRaw(it.toInt()) } } 130.toByte() -> { return (readValue(buffer) as? List)?.let { - PlatformAlbum.fromList(it) + PlatformAsset.fromList(it) } } 131.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDelta.fromList(it) + PlatformAlbum.fromList(it) } } 132.toByte() -> { return (readValue(buffer) as? List)?.let { - HashResult.fromList(it) + SyncDelta.fromList(it) } } 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + HashResult.fromList(it) + } + } + 134.toByte() -> { return (readValue(buffer) as? List)?.let { CloudIdResult.fromList(it) } @@ -319,26 +342,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { when (value) { - is PlatformAsset -> { + is PlatformAssetPlaybackStyle -> { stream.write(129) - writeValue(stream, value.toList()) + writeValue(stream, value.raw) } - is PlatformAlbum -> { + is PlatformAsset -> { stream.write(130) writeValue(stream, value.toList()) } - is SyncDelta -> { + is PlatformAlbum -> { stream.write(131) writeValue(stream, value.toList()) } - is HashResult -> { + is SyncDelta -> { stream.write(132) writeValue(stream, value.toList()) } - is CloudIdResult -> { + is HashResult -> { stream.write(133) writeValue(stream, value.toList()) } + is CloudIdResult -> { + stream.write(134) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 1b04fa50eb..05671579ae 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -4,11 +4,19 @@ import android.annotation.SuppressLint import android.content.ContentUris import android.content.Context import android.database.Cursor +import androidx.exifinterface.media.ExifInterface +import android.os.Build import android.os.Bundle +import android.os.ext.SdkExtensions import android.provider.MediaStore import android.util.Base64 +import android.util.Log import androidx.core.database.getStringOrNull import app.alextran.immich.core.ImmichPlugin +import com.bumptech.glide.Glide +import com.bumptech.glide.load.ImageHeaderParser +import com.bumptech.glide.load.ImageHeaderParserUtils +import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -28,6 +36,8 @@ sealed class AssetResult { data class InvalidAsset(val assetId: String) : AssetResult() } +private const val TAG = "NativeSyncApiImplBase" + @SuppressLint("InlinedApi") open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { private val ctx: Context = context.applicationContext @@ -39,6 +49,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS) private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED" + // MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+ + // https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT + private const val SPECIAL_FORMAT_COLUMN = "_special_format" + private const val SPECIAL_FORMAT_GIF = 1 + private const val SPECIAL_FORMAT_MOTION_PHOTO = 2 + private const val SPECIAL_FORMAT_ANIMATED_WEBP = 3 + const val MEDIA_SELECTION = "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" val MEDIA_SELECTION_ARGS = arrayOf( @@ -60,12 +77,28 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { add(MediaStore.MediaColumns.DURATION) add(MediaStore.MediaColumns.ORIENTATION) // IS_FAVORITE is only available on Android 11 and above - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { add(MediaStore.MediaColumns.IS_FAVORITE) } + if (hasSpecialFormatColumn()) { + add(SPECIAL_FORMAT_COLUMN) + } else { + // fallback to mimetype and xmp for playback style detection on older Android versions + // both only needed if special format column is not available + add(MediaStore.MediaColumns.MIME_TYPE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + add(MediaStore.MediaColumns.XMP) + } + } }.toTypedArray() const val HASH_BUFFER_SIZE = 2 * 1024 * 1024 + + // _special_format requires S Extensions 21+ + // https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT + private fun hasSpecialFormatColumn(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21 } protected fun getCursor( @@ -102,6 +135,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) @@ -109,9 +143,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val orientationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION) val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE) + val specialFormatColumn = c.getColumnIndex(SPECIAL_FORMAT_COLUMN) + val xmpColumn = c.getColumnIndex(MediaStore.MediaColumns.XMP) while (c.moveToNext()) { - val id = c.getLong(idColumn).toString() + val numericId = c.getLong(idColumn) + val id = numericId.toString() val name = c.getStringOrNull(nameColumn) val bucketId = c.getStringOrNull(bucketIdColumn) val path = c.getStringOrNull(dataColumn) @@ -125,10 +162,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { continue } - val mediaType = when (c.getInt(mediaTypeColumn)) { - MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1 - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2 - else -> 0 + val rawMediaType = c.getInt(mediaTypeColumn) + val assetType: Long = when (rawMediaType) { + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L + else -> 0L } // Date taken is milliseconds since epoch, Date added is seconds since epoch val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) @@ -138,22 +176,28 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val width = c.getInt(widthColumn).toLong() val height = c.getInt(heightColumn).toLong() // Duration is milliseconds - val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 + val duration = if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0L else c.getLong(durationColumn) / 1000 val orientation = c.getInt(orientationColumn) val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 + val playbackStyle = detectPlaybackStyle( + numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c + ) + + val isFlipped = orientation == 90 || orientation == 270 val asset = PlatformAsset( id, name, - mediaType.toLong(), + assetType, createdAt, modifiedAt, - width, - height, + if (isFlipped) height else width, + if (isFlipped) width else height, duration, - orientation.toLong(), + 0L, isFavorite, + playbackStyle = playbackStyle, ) yield(AssetResult.ValidAsset(asset, bucketId)) } @@ -161,6 +205,92 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } } + /** + * Detects the playback style for an asset using _special_format (SDK Extension 21+) + * or XMP / MIME / RIFF header fallbacks. + */ + @SuppressLint("NewApi") + private fun detectPlaybackStyle( + assetId: Long, + rawMediaType: Int, + mimeTypeColumn: Int, + specialFormatColumn: Int, + xmpColumn: Int, + cursor: Cursor + ): PlatformAssetPlaybackStyle { + // video currently has no special formats, so we can short circuit and avoid unnecessary work + if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) { + return PlatformAssetPlaybackStyle.VIDEO + } + + // API 33+: use _special_format from cursor + if (specialFormatColumn != -1) { + val specialFormat = cursor.getInt(specialFormatColumn) + return when { + specialFormat == SPECIAL_FORMAT_MOTION_PHOTO -> PlatformAssetPlaybackStyle.LIVE_PHOTO + specialFormat == SPECIAL_FORMAT_GIF || specialFormat == SPECIAL_FORMAT_ANIMATED_WEBP -> PlatformAssetPlaybackStyle.IMAGE_ANIMATED + rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> PlatformAssetPlaybackStyle.IMAGE + else -> PlatformAssetPlaybackStyle.UNKNOWN + } + } + + if (rawMediaType != MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) { + return PlatformAssetPlaybackStyle.UNKNOWN + } + + val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null + + // GIFs are always animated and cannot be motion photos; no I/O needed + if (mimeType == "image/gif") { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } + + val uri = ContentUris.withAppendedId( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + assetId + ) + + // Only WebP needs a stream check to distinguish static vs animated; + // WebP files are not used as motion photos, so skip XMP detection + if (mimeType == "image/webp") { + try { + val glide = Glide.get(ctx) + ctx.contentResolver.openInputStream(uri)?.use { stream -> + val type = ImageHeaderParserUtils.getType( + listOf(DefaultImageHeaderParser()), + stream, + glide.arrayPool + ) + // Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance + if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse image header for asset $assetId", e) + } + // if mimeType is webp but not animated, its just an image. + return PlatformAssetPlaybackStyle.IMAGE + } + + + // Read XMP from cursor (API 30+) + val xmp: String? = if (xmpColumn != -1) { + cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8) + } else { + // if xmp column is not available, we are on API 29 or below + // theoretically there were motion photos but the Camera:MotionPhoto xmp tag + // was only added in Android 11, so we should not have to worry about parsing XMP on older versions + null + } + + if (xmp != null && "Camera:MotionPhoto" in xmp) { + return PlatformAssetPlaybackStyle.LIVE_PHOTO + } + + return PlatformAssetPlaybackStyle.IMAGE + } + fun getAlbums(): List { val albums = mutableListOf() val albumsCount = mutableMapOf() diff --git a/mobile/drift_schemas/main/drift_schema_v21.json b/mobile/drift_schemas/main/drift_schema_v21.json index 3910751209..4a6654ba4f 100644 --- a/mobile/drift_schemas/main/drift_schema_v21.json +++ b/mobile/drift_schemas/main/drift_schema_v21.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_edited","getter_name":"isEdited","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_edited\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_edited\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[6],"type":"index","data":{"on":6,"name":"idx_local_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_remote_album_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)","unique":false,"columns":[]}},{"id":9,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":10,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":11,"references":[2],"type":"index","data":{"on":2,"name":"idx_stack_primary_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)","unique":false,"columns":[]}},{"id":12,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":13,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":14,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":15,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":16,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_stack_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)","unique":false,"columns":[]}},{"id":17,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_day","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))","unique":false,"columns":[]}},{"id":18,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_month","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))","unique":false,"columns":[]}},{"id":19,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":21,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":22,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":23,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":24,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":25,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":26,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":27,"references":[1,26],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":28,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":29,"references":[1,28],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_visible","getter_name":"isVisible","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_visible\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_visible\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":30,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":31,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":32,"references":[1],"type":"table","data":{"name":"asset_ocr_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"x1","getter_name":"x1","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"y1","getter_name":"y1","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"x2","getter_name":"x2","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"y2","getter_name":"y2","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"x3","getter_name":"x3","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"y3","getter_name":"y3","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"x4","getter_name":"x4","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"y4","getter_name":"y4","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"box_score","getter_name":"boxScore","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"text_score","getter_name":"textScore","moor_type":"double","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"text","getter_name":"recognizedText","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_visible","getter_name":"isVisible","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_visible\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_visible\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":33,"references":[21],"type":"index","data":{"on":21,"name":"idx_partner_shared_with_id","sql":"CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)","unique":false,"columns":[]}},{"id":34,"references":[22],"type":"index","data":{"on":22,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":35,"references":[23],"type":"index","data":{"on":23,"name":"idx_remote_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":36,"references":[25],"type":"index","data":{"on":25,"name":"idx_remote_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)","unique":false,"columns":[]}},{"id":37,"references":[28],"type":"index","data":{"on":28,"name":"idx_person_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)","unique":false,"columns":[]}},{"id":38,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_person_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)","unique":false,"columns":[]}},{"id":39,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)","unique":false,"columns":[]}},{"id":40,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":41,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_edited","getter_name":"isEdited","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_edited\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_edited\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"playback_style","getter_name":"playbackStyle","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetPlaybackStyle.values)","dart_type_name":"AssetPlaybackStyle"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[6],"type":"index","data":{"on":6,"name":"idx_local_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_remote_album_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)","unique":false,"columns":[]}},{"id":9,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":10,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":11,"references":[2],"type":"index","data":{"on":2,"name":"idx_stack_primary_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)","unique":false,"columns":[]}},{"id":12,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":13,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":14,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":15,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":16,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_stack_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)","unique":false,"columns":[]}},{"id":17,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_day","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))","unique":false,"columns":[]}},{"id":18,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_month","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))","unique":false,"columns":[]}},{"id":19,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":21,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":22,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":23,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":24,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":25,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":26,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":27,"references":[1,26],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":28,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":29,"references":[1,28],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_visible","getter_name":"isVisible","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_visible\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_visible\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":30,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":31,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}},{"name":"playback_style","getter_name":"playbackStyle","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetPlaybackStyle.values)","dart_type_name":"AssetPlaybackStyle"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":32,"references":[21],"type":"index","data":{"on":21,"name":"idx_partner_shared_with_id","sql":"CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)","unique":false,"columns":[]}},{"id":33,"references":[22],"type":"index","data":{"on":22,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":34,"references":[23],"type":"index","data":{"on":23,"name":"idx_remote_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":35,"references":[25],"type":"index","data":{"on":25,"name":"idx_remote_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)","unique":false,"columns":[]}},{"id":36,"references":[28],"type":"index","data":{"on":28,"name":"idx_person_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)","unique":false,"columns":[]}},{"id":37,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_person_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)","unique":false,"columns":[]}},{"id":38,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)","unique":false,"columns":[]}},{"id":39,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":40,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}}]} \ No newline at end of file diff --git a/mobile/drift_schemas/main/drift_schema_v22.json b/mobile/drift_schemas/main/drift_schema_v22.json new file mode 100644 index 0000000000..ff8856addd --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v22.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_edited","getter_name":"isEdited","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_edited\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_edited\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"playback_style","getter_name":"playbackStyle","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetPlaybackStyle.values)","dart_type_name":"AssetPlaybackStyle"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[6],"type":"index","data":{"on":6,"name":"idx_local_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_remote_album_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)","unique":false,"columns":[]}},{"id":9,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":10,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":11,"references":[2],"type":"index","data":{"on":2,"name":"idx_stack_primary_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)","unique":false,"columns":[]}},{"id":12,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":13,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":14,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":15,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":16,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_stack_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)","unique":false,"columns":[]}},{"id":17,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_day","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))","unique":false,"columns":[]}},{"id":18,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_month","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))","unique":false,"columns":[]}},{"id":19,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":21,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":22,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":23,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":24,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":25,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":26,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":27,"references":[1,26],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":28,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":29,"references":[1,28],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_visible","getter_name":"isVisible","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_visible\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_visible\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":30,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":31,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}},{"name":"playback_style","getter_name":"playbackStyle","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetPlaybackStyle.values)","dart_type_name":"AssetPlaybackStyle"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":32,"references":[1],"type":"table","data":{"name":"asset_edit_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"action","getter_name":"action","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetEditAction.values)","dart_type_name":"AssetEditAction"}},{"name":"parameters","getter_name":"parameters","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"editParameterConverter","dart_type_name":"Map"}},{"name":"sequence","getter_name":"sequence","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":33,"references":[21],"type":"index","data":{"on":21,"name":"idx_partner_shared_with_id","sql":"CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)","unique":false,"columns":[]}},{"id":34,"references":[22],"type":"index","data":{"on":22,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":35,"references":[23],"type":"index","data":{"on":23,"name":"idx_remote_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":36,"references":[25],"type":"index","data":{"on":25,"name":"idx_remote_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)","unique":false,"columns":[]}},{"id":37,"references":[28],"type":"index","data":{"on":28,"name":"idx_person_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)","unique":false,"columns":[]}},{"id":38,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_person_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)","unique":false,"columns":[]}},{"id":39,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)","unique":false,"columns":[]}},{"id":40,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":41,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}},{"id":42,"references":[32],"type":"index","data":{"on":32,"name":"idx_asset_edit_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)","unique":false,"columns":[]}}]} \ No newline at end of file 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 0f678ce4a4..5a8075f91a 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -221,8 +221,11 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NetworkApi { func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) - func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) func removeCertificate(completion: @escaping (Result) -> Void) + func hasCertificate() throws -> Bool + func getClientPointer() throws -> Int64 + func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -255,8 +258,8 @@ class NetworkApiSetup { let promptTextArg = args[0] as! ClientCertPrompt api.selectCertificate(promptText: promptTextArg) { result in switch result { - case .success(let res): - reply(wrapResult(res)) + case .success: + reply(wrapResult(nil)) case .failure(let error): reply(wrapError(error)) } @@ -280,5 +283,48 @@ class NetworkApiSetup { } else { removeCertificateChannel.setMessageHandler(nil) } + let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + hasCertificateChannel.setMessageHandler { _, reply in + do { + let result = try api.hasCertificate() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + hasCertificateChannel.setMessageHandler(nil) + } + let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getClientPointerChannel.setMessageHandler { _, reply in + do { + let result = try api.getClientPointer() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getClientPointerChannel.setMessageHandler(nil) + } + let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setRequestHeadersChannel.setMessageHandler { message, reply in + 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, token: tokenArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setRequestHeadersChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index d67c392a3a..3c4be8e718 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -1,5 +1,6 @@ import Foundation import UniformTypeIdentifiers +import native_video_player enum ImportError: Error { case noFile @@ -16,14 +17,25 @@ class NetworkApiImpl: NetworkApi { self.viewController = viewController } - func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { let importer = CertImporter(promptText: promptText, completion: { [weak self] result in self?.activeImporter = nil - completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) + completion(result) }, viewController: viewController) activeImporter = importer importer.load() } + + func hasCertificate() throws -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecReturnRef as String: true, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + return status == errSecSuccess + } func removeCertificate(completion: @escaping (Result) -> Void) { let status = clearCerts() @@ -40,14 +52,55 @@ class NetworkApiImpl: NetworkApi { } completion(.failure(ImportError.keychainError(status))) } + + func getClientPointer() throws -> Int64 { + let pointer = URLSessionManager.shared.sessionPointer + return Int64(Int(bitPattern: pointer)) + } + + 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 values: [AuthCookie: String] = [ + .accessToken: token, + .isAuthenticated: "true", + .authType: "password", + ] + for (cookie, value) in values { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: cookie.name, + .value: value, + .domain: domain, + .path: "/", + .expires: expiry, + ] + if isSecure { properties[.secure] = "TRUE" } + if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" } + if let httpCookie = HTTPCookie(properties: properties) { + URLSessionManager.cookieStorage.setCookie(httpCookie) + } + } + } + } + + if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { + UserDefaults.group.set(headers, forKey: HEADERS_KEY) + URLSessionManager.shared.recreateSession() + } + } } private class CertImporter: NSObject, UIDocumentPickerDelegate { private let promptText: ClientCertPrompt - private var completion: ((Result<(Data, String), Error>) -> Void) + private var completion: ((Result) -> Void) private weak var viewController: UIViewController? - - init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { + + init(promptText: ClientCertPrompt, completion: (@escaping (Result) -> Void), viewController: UIViewController?) { self.promptText = promptText self.completion = completion self.viewController = viewController @@ -81,7 +134,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate { } await URLSessionManager.shared.session.flush() - self.completion(.success((data, password))) + self.completion(.success(())) } catch { completion(.failure(error)) } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 73145dbce5..0b73ed71a6 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -1,71 +1,241 @@ import Foundation +import native_video_player let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" +let HEADERS_KEY = "immich.request_headers" +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)! +} /// Manages a shared URLSession with SSL configuration support. +/// Old sessions are kept alive by Dart's FFI retain until all isolates release them. class URLSessionManager: NSObject { static let shared = URLSessionManager() - let session: URLSession - private let configuration = { - let config = URLSessionConfiguration.default - - let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + private(set) var session: URLSession + let delegate: URLSessionManagerDelegate + private static let cacheDir: URL = { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) .first! .appendingPathComponent("api", isDirectory: true) - try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - - config.urlCache = URLCache( - memoryCapacity: 0, - diskCapacity: 1024 * 1024 * 1024, - directory: cacheDir + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + private static let urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 1024 * 1024 * 1024, + directory: cacheDir + ) + 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 + config.httpCookieStorage = cookieStorage config.httpMaximumConnectionsPerHost = 64 config.timeoutIntervalForRequest = 60 config.timeoutIntervalForResource = 300 - - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" - config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] - - return config - }() - - private override init() { - session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) - super.init() + + var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:] + headers["User-Agent"] = headers["User-Agent"] ?? userAgent + config.httpAdditionalHeaders = headers + + 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@:@@@@?") } } -class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { +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 { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - handleChallenge(challenge, completionHandler: completionHandler) + handleChallenge(session, challenge, completionHandler) } - + func urlSession( _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - handleChallenge(challenge, completionHandler: completionHandler) + handleChallenge(session, challenge, completionHandler, task: task) } - + func handleChallenge( + _ session: URLSession, _ challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + _ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void, + task: URLSessionTask? = nil ) { switch challenge.protectionSpace.authenticationMethod { - case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler) + case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler) + case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler) default: completionHandler(.performDefaultHandling, nil) } } - + private func handleClientCertificate( + _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { let query: [String: Any] = [ @@ -73,15 +243,36 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { 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 { let credential = URLCredential(identity: identity as! SecIdentity, certificates: nil, persistence: .forSession) + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } return completion(.useCredential, credential) } completion(.performDefaultHandling, nil) } + + private func handleBasicAuth( + _ session: URLSession, + task: URLSessionTask?, + completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard let url = task?.originalRequest?.url, + let user = url.user, + let password = url.password + else { + return completion(.performDefaultHandling, nil) + } + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } + let credential = URLCredential(user: user, password: password, persistence: .forSession) + completion(.useCredential, credential) + } } diff --git a/mobile/ios/Runner/Images/LocalImages.g.swift b/mobile/ios/Runner/Images/LocalImages.g.swift index d417f10222..146950cd51 100644 --- a/mobile/ios/Runner/Images/LocalImages.g.swift +++ b/mobile/ios/Runner/Images/LocalImages.g.swift @@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol LocalImageApi { - func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void) } @@ -90,7 +90,8 @@ class LocalImageApiSetup { let widthArg = args[2] as! Int64 let heightArg = args[3] as! Int64 let isVideoArg = args[4] as! Bool - api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in + let preferEncodedArg = args[5] as! Bool + api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 96e1b60a2f..303ff5bc33 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -7,7 +7,7 @@ class LocalImageRequest { weak var workItem: DispatchWorkItem? var isCancelled = false let callback: (Result<[String: Int64]?, any Error>) -> Void - + init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.callback = callback } @@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi { requestOptions.version = .current return requestOptions }() - + private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated) private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated) private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) - + private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, @@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi { assetCache.countLimit = 10000 return assetCache }() - + func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { ImageProcessing.queue.async { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} - + let (width, height, pointer) = thumbHashToRGBA(hash: data) completion(.success([ "pointer": Int64(Int(bitPattern: pointer.baseAddress)), @@ -63,34 +63,77 @@ class LocalImageApiImpl: LocalImageApi { ])) } } - - func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { let request = LocalImageRequest(callback: completion) let item = DispatchWorkItem { if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + ImageProcessing.semaphore.wait() defer { ImageProcessing.semaphore.signal() } - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + guard let asset = Self.requestAsset(assetId: assetId) else { Self.remove(requestId: requestId) completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) return } - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + + if preferEncoded { + let dataOptions = PHImageRequestOptions() + dataOptions.isNetworkAccessAllowed = true + dataOptions.isSynchronous = true + dataOptions.version = .current + + var imageData: Data? + Self.imageManager.requestImageDataAndOrientation( + for: asset, + options: dataOptions, + resultHandler: { (data, _, _, _) in + imageData = data + } + ) + + if request.isCancelled { + Self.remove(requestId: requestId) + return completion(ImageProcessing.cancelledResult) + } + + guard let data = imageData else { + Self.remove(requestId: requestId) + return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) + } + + let length = data.count + let pointer = malloc(length)! + data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) + + if request.isCancelled { + free(pointer) + Self.remove(requestId: requestId) + return completion(ImageProcessing.cancelledResult) + } + + request.callback(.success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) + Self.remove(requestId: requestId) + return + } + var image: UIImage? Self.imageManager.requestImage( for: asset, @@ -101,29 +144,29 @@ class LocalImageApiImpl: LocalImageApi { image = _image } ) - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + guard let image = image, let cgImage = image.cgImage else { Self.remove(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + do { let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) - + if request.isCancelled { buffer.free() return completion(ImageProcessing.cancelledResult) } - + request.callback(.success([ "pointer": Int64(Int(bitPattern: buffer.data)), "width": Int64(buffer.width), @@ -136,24 +179,24 @@ class LocalImageApiImpl: LocalImageApi { return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } } - + request.workItem = item Self.add(requestId: requestId, request: request) ImageProcessing.queue.async(execute: item) } - + func cancelRequest(requestId: Int64) { Self.cancel(requestId: requestId) } - + private static func add(requestId: Int64, request: LocalImageRequest) -> Void { requestQueue.sync { requests[requestId] = request } } - + private static func remove(requestId: Int64) -> Void { requestQueue.sync { requests[requestId] = nil } } - + private static func cancel(requestId: Int64) -> Void { requestQueue.async { guard let request = requests.removeValue(forKey: requestId) else { return } @@ -164,12 +207,12 @@ class LocalImageApiImpl: LocalImageApi { } } } - + private static func requestAsset(assetId: String) -> PHAsset? { var asset: PHAsset? assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) } if asset != nil { return asset } - + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject else { return nil } assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index fc83b09d4b..9fcffd4233 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol RemoteImageApi { - func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws func clearCache(completion: @escaping (Result) -> Void) } @@ -86,9 +86,9 @@ class RemoteImageApiSetup { requestImageChannel.setMessageHandler { message, reply in let args = message as! [Any?] let urlArg = args[0] as! String - let headersArg = args[1] as! [String: String] - let requestIdArg = args[2] as! Int64 - api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in + let requestIdArg = args[1] as! Int64 + let preferEncodedArg = args[2] as! Bool + api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 56e8938521..f2a0c37254 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -8,7 +8,7 @@ class RemoteImageRequest { let id: Int64 var isCancelled = false let completion: (Result<[String: Int64]?, any Error>) -> Void - + init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.id = id self.task = task @@ -32,75 +32,90 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailFromImageAlways: true ] as CFDictionary - - func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + + func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { var urlRequest = URLRequest(url: URL(string: url)!) urlRequest.cachePolicy = .returnCacheDataElseLoad - for (key, value) in headers { - urlRequest.setValue(value, forHTTPHeaderField: key) - } - + let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in - Self.handleCompletion(requestId: requestId, data: data, response: response, error: error) + Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) } - + let request = RemoteImageRequest(id: requestId, task: task, completion: completion) - + os_unfair_lock_lock(&Self.lock) Self.requests[requestId] = request os_unfair_lock_unlock(&Self.lock) - + task.resume() } - - private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) { + + private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) { os_unfair_lock_lock(&Self.lock) guard let request = requests[requestId] else { return os_unfair_lock_unlock(&Self.lock) } requests[requestId] = nil os_unfair_lock_unlock(&Self.lock) - + if let error = error { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { return request.completion(ImageProcessing.cancelledResult) } return request.completion(.failure(error)) } - + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } - + guard let data = data else { return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } - + ImageProcessing.queue.async { ImageProcessing.semaphore.wait() defer { ImageProcessing.semaphore.signal() } - + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } - + + // Return raw encoded bytes when requested (for animated images) + if encoded { + let length = data.count + let pointer = malloc(length)! + data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) + + if request.isCancelled { + free(pointer) + return request.completion(ImageProcessing.cancelledResult) + } + + return request.completion( + .success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) + } + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) } - + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } - + do { let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) - + if request.isCancelled { buffer.free() return request.completion(ImageProcessing.cancelledResult) } - + request.completion( .success([ "pointer": Int64(Int(bitPattern: buffer.data)), @@ -113,17 +128,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } } } - + func cancelRequest(requestId: Int64) { os_unfair_lock_lock(&Self.lock) let request = Self.requests[requestId] os_unfair_lock_unlock(&Self.lock) - + guard let request = request else { return } request.isCancelled = true request.task?.cancel() } - + func clearCache(completion: @escaping (Result) -> Void) { Task { let cache = URLSessionManager.shared.session.configuration.urlCache! diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index e18af39e04..6bba25d94b 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -128,6 +128,15 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) { +enum PlatformAssetPlaybackStyle: Int { + case unknown = 0 + case image = 1 + case video = 2 + case imageAnimated = 3 + case livePhoto = 4 + case videoLooping = 5 +} + /// Generated class from Pigeon that represents data sent in messages. struct PlatformAsset: Hashable { var id: String @@ -143,6 +152,7 @@ struct PlatformAsset: Hashable { var adjustmentTime: Int64? = nil var latitude: Double? = nil var longitude: Double? = nil + var playbackStyle: PlatformAssetPlaybackStyle // swift-format-ignore: AlwaysUseLowerCamelCase @@ -160,6 +170,7 @@ struct PlatformAsset: Hashable { let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10]) let latitude: Double? = nilOrValue(pigeonVar_list[11]) let longitude: Double? = nilOrValue(pigeonVar_list[12]) + let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle return PlatformAsset( id: id, @@ -174,7 +185,8 @@ struct PlatformAsset: Hashable { isFavorite: isFavorite, adjustmentTime: adjustmentTime, latitude: latitude, - longitude: longitude + longitude: longitude, + playbackStyle: playbackStyle ) } func toList() -> [Any?] { @@ -192,6 +204,7 @@ struct PlatformAsset: Hashable { adjustmentTime, latitude, longitude, + playbackStyle, ] } static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { @@ -349,14 +362,20 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { case 129: - return PlatformAsset.fromList(self.readValue() as! [Any?]) + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return PlatformAssetPlaybackStyle(rawValue: enumResultAsInt) + } + return nil case 130: - return PlatformAlbum.fromList(self.readValue() as! [Any?]) + return PlatformAsset.fromList(self.readValue() as! [Any?]) case 131: - return SyncDelta.fromList(self.readValue() as! [Any?]) + return PlatformAlbum.fromList(self.readValue() as! [Any?]) case 132: - return HashResult.fromList(self.readValue() as! [Any?]) + return SyncDelta.fromList(self.readValue() as! [Any?]) case 133: + return HashResult.fromList(self.readValue() as! [Any?]) + case 134: return CloudIdResult.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -366,21 +385,24 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { private class MessagesPigeonCodecWriter: FlutterStandardWriter { override func writeValue(_ value: Any) { - if let value = value as? PlatformAsset { + if let value = value as? PlatformAssetPlaybackStyle { super.writeByte(129) - super.writeValue(value.toList()) - } else if let value = value as? PlatformAlbum { + super.writeValue(value.rawValue) + } else if let value = value as? PlatformAsset { super.writeByte(130) super.writeValue(value.toList()) - } else if let value = value as? SyncDelta { + } else if let value = value as? PlatformAlbum { super.writeByte(131) super.writeValue(value.toList()) - } else if let value = value as? HashResult { + } else if let value = value as? SyncDelta { super.writeByte(132) super.writeValue(value.toList()) - } else if let value = value as? CloudIdResult { + } else if let value = value as? HashResult { super.writeByte(133) super.writeValue(value.toList()) + } else if let value = value as? CloudIdResult { + super.writeByte(134) + super.writeValue(value.toList()) } else { super.writeValue(value) } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 0650b47879..8022fb06d2 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -173,7 +173,8 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { type: 0, durationInSeconds: 0, orientation: 0, - isFavorite: false + isFavorite: false, + playbackStyle: .unknown ) if (updatedAssets.contains(AssetWrapper(with: predicate))) { continue diff --git a/mobile/ios/Runner/Sync/PHAssetExtensions.swift b/mobile/ios/Runner/Sync/PHAssetExtensions.swift index f555d75bd0..0fc1dfc701 100644 --- a/mobile/ios/Runner/Sync/PHAssetExtensions.swift +++ b/mobile/ios/Runner/Sync/PHAssetExtensions.swift @@ -1,6 +1,17 @@ import Photos extension PHAsset { + var platformPlaybackStyle: PlatformAssetPlaybackStyle { + switch playbackStyle { + case .image: return .image + case .imageAnimated: return .imageAnimated + case .livePhoto: return .livePhoto + case .video: return .video + case .videoLooping: return .videoLooping + @unknown default: return .unknown + } + } + func toPlatformAsset() -> PlatformAsset { return PlatformAsset( id: localIdentifier, @@ -15,7 +26,8 @@ extension PHAsset { isFavorite: isFavorite, adjustmentTime: adjustmentTimestamp, latitude: location?.coordinate.latitude, - longitude: location?.coordinate.longitude + longitude: location?.coordinate.longitude, + playbackStyle: platformPlaybackStyle ) } @@ -26,7 +38,7 @@ extension PHAsset { var filename: String? { return value(forKey: "filename") as? String } - + var adjustmentTimestamp: Int64? { if let date = value(forKey: "adjustmentTimestamp") as? Date { return Int64(date.timeIntervalSince1970) 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 310e30ea62..cb40c8f76a 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -11,6 +11,10 @@ enum AssetType { enum AssetState { local, remote, merged } +// do not change! +// keep in sync with PlatformAssetPlaybackStyle +enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } + sealed class BaseAsset { final String name; final String? checksum; @@ -42,6 +46,15 @@ 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; + if (isMotionPhoto) return AssetPlaybackStyle.livePhoto; + if (isImage && durationInSeconds != null && durationInSeconds! > 0) return AssetPlaybackStyle.imageAnimated; + if (isImage) return AssetPlaybackStyle.image; + return AssetPlaybackStyle.unknown; + } Duration get duration { final durationInSeconds = this.durationInSeconds; diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 887dfd3834..6766f4c3a2 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -5,6 +5,8 @@ class LocalAsset extends BaseAsset { final String? remoteAssetId; final String? cloudId; final int orientation; + @override + final AssetPlaybackStyle playbackStyle; final DateTime? adjustmentTime; final double? latitude; @@ -25,6 +27,7 @@ class LocalAsset extends BaseAsset { super.isFavorite = false, super.livePhotoVideoId, this.orientation = 0, + required this.playbackStyle, this.adjustmentTime, this.latitude, this.longitude, @@ -56,6 +59,7 @@ class LocalAsset extends BaseAsset { width: ${width ?? ""}, height: ${height ?? ""}, durationInSeconds: ${durationInSeconds ?? ""}, + playbackStyle: $playbackStyle, remoteId: ${remoteId ?? ""}, cloudId: ${cloudId ?? ""}, checksum: ${checksum ?? ""}, @@ -76,6 +80,7 @@ class LocalAsset extends BaseAsset { id == other.id && cloudId == other.cloudId && orientation == other.orientation && + playbackStyle == other.playbackStyle && adjustmentTime == other.adjustmentTime && latitude == other.latitude && longitude == other.longitude; @@ -87,6 +92,7 @@ class LocalAsset extends BaseAsset { id.hashCode ^ remoteId.hashCode ^ orientation.hashCode ^ + playbackStyle.hashCode ^ adjustmentTime.hashCode ^ latitude.hashCode ^ longitude.hashCode; @@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset { int? durationInSeconds, bool? isFavorite, int? orientation, + AssetPlaybackStyle? playbackStyle, DateTime? adjustmentTime, double? latitude, double? longitude, @@ -124,6 +131,7 @@ class LocalAsset extends BaseAsset { durationInSeconds: durationInSeconds ?? this.durationInSeconds, isFavorite: isFavorite ?? this.isFavorite, orientation: orientation ?? this.orientation, + playbackStyle: playbackStyle ?? this.playbackStyle, adjustmentTime: adjustmentTime ?? this.adjustmentTime, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, diff --git a/mobile/lib/domain/models/asset_edit.model.dart b/mobile/lib/domain/models/asset_edit.model.dart new file mode 100644 index 0000000000..b3266dba46 --- /dev/null +++ b/mobile/lib/domain/models/asset_edit.model.dart @@ -0,0 +1,21 @@ +import "package:openapi/api.dart" as api show AssetEditAction; + +enum AssetEditAction { rotate, crop, mirror, other } + +extension AssetEditActionExtension on AssetEditAction { + api.AssetEditAction? toDto() { + return switch (this) { + AssetEditAction.rotate => api.AssetEditAction.rotate, + AssetEditAction.crop => api.AssetEditAction.crop, + AssetEditAction.mirror => api.AssetEditAction.mirror, + AssetEditAction.other => null, + }; + } +} + +class AssetEdit { + final AssetEditAction action; + final Map parameters; + + const AssetEdit({required this.action, required this.parameters}); +} diff --git a/mobile/lib/domain/models/search_result.model.dart b/mobile/lib/domain/models/search_result.model.dart index 947bc6192f..21134b73d8 100644 --- a/mobile/lib/domain/models/search_result.model.dart +++ b/mobile/lib/domain/models/search_result.model.dart @@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; class SearchResult { final List assets; - final double scrollOffset; final int? nextPage; - const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage}); - - SearchResult copyWith({List? assets, int? nextPage, double? scrollOffset}) { - return SearchResult( - assets: assets ?? this.assets, - nextPage: nextPage ?? this.nextPage, - scrollOffset: scrollOffset ?? this.scrollOffset, - ); - } + const SearchResult({required this.assets, this.nextPage}); @override - String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)'; + String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)'; @override bool operator ==(covariant SearchResult other) { if (identical(this, other)) return true; final listEquals = const DeepCollectionEquality().equals; - return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset; + return listEquals(other.assets, assets) && other.nextPage == nextPage; } @override - int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode; + int get hashCode => assets.hashCode ^ nextPage.hashCode; } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 6de13b6244..93a2a14127 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:ui'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final Drift _drift; final DriftLogger _driftLogger; final BackgroundWorkerBgHostApi _backgroundHostApi; - final CancellationToken _cancellationToken = CancellationToken(); + final _cancellationToken = Completer(); final Logger _logger = Logger('BackgroundWorkerBgService'); bool _isCleanedUp = false; @@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - HttpSSLOptions.apply(); - await Future.wait( [ loadTranslations(), @@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _ref?.dispose(); _ref = null; - _cancellationToken.cancel(); + _cancellationToken.complete(); _logger.info("Cleaning up background worker"); final cleanupFutures = [ diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 868f153157..029482978a 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -435,9 +435,19 @@ extension PlatformToLocalAsset on PlatformAsset { durationInSeconds: durationInSeconds, isFavorite: isFavorite, orientation: orientation, + playbackStyle: _toPlaybackStyle(playbackStyle), adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true), latitude: latitude, longitude: longitude, isEdited: false, ); } + +AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) { + PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown, + PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image, + PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video, + PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated, + PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto, + PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping, +}; diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart index a3f935c492..004ad06b1b 100644 --- a/mobile/lib/domain/services/search.service.dart +++ b/mobile/lib/domain/services/search.service.dart @@ -70,13 +70,14 @@ extension on AssetResponseDto { _ => AssetVisibility.timeline, }, durationInSeconds: duration.toDuration()?.inSeconds ?? 0, - height: exifInfo?.exifImageHeight?.toInt(), - width: exifInfo?.exifImageWidth?.toInt(), + height: height?.toInt(), + width: width?.toInt(), isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, thumbHash: thumbhash, localId: null, type: type.toAssetType(), + stackId: stack?.id, isEdited: isEdited, ); } diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 198efd895b..199c5532e2 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -205,6 +205,10 @@ class SyncStreamService { return _syncStreamRepository.deleteAssetsV1(data.cast()); case SyncEntityType.assetExifV1: return _syncStreamRepository.updateAssetsExifV1(data.cast()); + case SyncEntityType.assetEditV1: + return _syncStreamRepository.updateAssetEditsV1(data.cast()); + case SyncEntityType.assetEditDeleteV1: + return _syncStreamRepository.deleteAssetEditsV1(data.cast()); case SyncEntityType.assetMetadataV1: return _syncStreamRepository.updateAssetsMetadataV1(data.cast()); case SyncEntityType.assetMetadataDeleteV1: @@ -340,39 +344,43 @@ class SyncStreamService { } } - Future handleWsAssetEditReadyV1Batch(List batchData) async { - if (batchData.isEmpty) return; - - _logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events'); - - final List assets = []; + Future handleWsAssetEditReadyV1(dynamic data) async { + _logger.info('Processing AssetEditReadyV1 event'); try { - for (final data in batchData) { - if (data is! Map) { - continue; - } - - final payload = data; - final assetData = payload['asset']; - - if (assetData == null) { - continue; - } - - final asset = SyncAssetV1.fromJson(assetData); - - if (asset != null) { - assets.add(asset); - } + if (data is! Map) { + throw ArgumentError("Invalid data format for AssetEditReadyV1 event"); } - if (assets.isNotEmpty) { - await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit'); - _logger.info('Successfully processed ${assets.length} edited assets'); + final payload = data; + + if (payload['asset'] == null) { + throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data"); } + + final asset = SyncAssetV1.fromJson(payload['asset']); + if (asset == null) { + throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data"); + } + + List assetEdits = []; + + // Edits are only send on v2.6.0+ + if (payload['edit'] != null && payload['edit'] is List) { + assetEdits = (payload['edit'] as List) + .map((e) => SyncAssetEditV1.fromJson(e)) + .whereType() + .toList(); + } + + await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit'); + await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); + + _logger.info( + 'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits', + ); } catch (error, stackTrace) { - _logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace); + _logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace); } } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 39aeb867a3..b33940eacd 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -78,6 +78,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetStream(List Function() getAssets, Stream assetCount, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); @@ -112,7 +115,7 @@ class TimelineService { if (totalAssets == 0) { _bufferOffset = 0; - _buffer.clear(); + _buffer = []; } else { final int offset; final int count; diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 6840bae595..7c9b6ae061 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -196,11 +196,11 @@ class BackgroundSyncManager { }); } - Future syncWebsocketEditBatch(List batchData) { + Future syncWebsocketEdit(dynamic data) { if (_syncWebsocketTask != null) { return _syncWebsocketTask!.future; } - _syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData); + _syncWebsocketTask = _handleWsAssetEditReadyV1(data); return _syncWebsocketTask!.whenComplete(() { _syncWebsocketTask = null; }); @@ -242,7 +242,7 @@ Cancelable _handleWsAssetUploadReadyV1Batch(List batchData) => ru debugLabel: 'websocket-batch', ); -Cancelable _handleWsAssetEditReadyV1Batch(List batchData) => runInIsolateGentle( - computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData), +Cancelable _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle( + computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data), debugLabel: 'websocket-edit', ); diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 5917e127bc..5b8f9e2a13 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -33,12 +33,27 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { ); } +class SnapScrollController extends ScrollController { + SnapScrollPosition get snapPosition => position as SnapScrollPosition; + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) => + SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition); +} + +class SnapScrollPosition extends ScrollPositionWithSingleContext { + double snapOffset; + + SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0}); + + @override + bool get shouldIgnorePointer => false; +} + class SnapScrollPhysics extends ScrollPhysics { static const _minFlingVelocity = 700.0; static const minSnapDistance = 30.0; - static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300); - const SnapScrollPhysics({super.parent}); @override @@ -66,91 +81,21 @@ class SnapScrollPhysics extends ScrollPhysics { } } - return ScrollSpringSimulation( - _spring, - position.pixels, - target(position, velocity, snapOffset), - velocity, - tolerance: toleranceFor(position), - ); + return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity); } + @override + SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300); + + @override + bool get allowImplicitScrolling => false; + + @override + bool get allowUserScrolling => false; + static double target(ScrollMetrics position, double velocity, double snapOffset) { if (velocity > _minFlingVelocity) return snapOffset; if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset; return position.pixels < minSnapDistance ? 0.0 : snapOffset; } } - -class SnapScrollPosition extends ScrollPositionWithSingleContext { - double snapOffset; - - SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition}); -} - -class ProxyScrollController extends ScrollController { - final ScrollController scrollController; - - ProxyScrollController({required this.scrollController}); - - SnapScrollPosition get snapPosition => position as SnapScrollPosition; - - @override - ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { - return ProxyScrollPosition( - scrollController: scrollController, - physics: physics, - context: context, - oldPosition: oldPosition, - ); - } - - @override - void dispose() { - scrollController.dispose(); - super.dispose(); - } -} - -class ProxyScrollPosition extends SnapScrollPosition { - final ScrollController scrollController; - - ProxyScrollPosition({ - required this.scrollController, - required super.physics, - required super.context, - super.oldPosition, - }); - - @override - double setPixels(double newPixels) { - final overscroll = super.setPixels(newPixels); - if (scrollController.hasClients && scrollController.position.pixels != pixels) { - scrollController.position.forcePixels(pixels); - } - return overscroll; - } - - @override - void forcePixels(double value) { - super.forcePixels(value); - if (scrollController.hasClients && scrollController.position.pixels != pixels) { - scrollController.position.forcePixels(pixels); - } - } - - @override - double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions - ? scrollController.position.maxScrollExtent - : super.maxScrollExtent; - - @override - double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions - ? scrollController.position.minScrollExtent - : super.minScrollExtent; - - @override - double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension - ? scrollController.position.viewportDimension - : super.viewportDimension; -} diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.dart new file mode 100644 index 0000000000..22d059bdb4 --- /dev/null +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.dart @@ -0,0 +1,33 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)') +class AssetEditEntity extends Table with DriftDefaultsMixin { + const AssetEditEntity(); + + TextColumn get id => text()(); + + TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + IntColumn get action => intEnum()(); + + BlobColumn get parameters => blob().map(editParameterConverter)(); + + IntColumn get sequence => integer()(); + + @override + Set get primaryKey => {id}; +} + +final JsonTypeConverter2, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb( + fromJson: (json) => json as Map, +); + +extension AssetEditEntityDataDomainEx on AssetEditEntityData { + AssetEdit toDto() { + return AssetEdit(action: action, parameters: parameters); + } +} diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart new file mode 100644 index 0000000000..fc40bf9030 --- /dev/null +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart @@ -0,0 +1,752 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart' + as i1; +import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2; +import 'dart:typed_data' as i3; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart' + as i4; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i5; +import 'package:drift/internal/modular.dart' as i6; + +typedef $$AssetEditEntityTableCreateCompanionBuilder = + i1.AssetEditEntityCompanion Function({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }); +typedef $$AssetEditEntityTableUpdateCompanionBuilder = + i1.AssetEditEntityCompanion Function({ + i0.Value id, + i0.Value assetId, + i0.Value action, + i0.Value> parameters, + i0.Value sequence, + }); + +final class $$AssetEditEntityTableReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData + > { + $$AssetEditEntityTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i6.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') + .createAlias( + i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer(db) + .resultSet('asset_edit_entity') + .assetId, + i6.ReadDatabaseContainer( + db, + ).resultSet('remote_asset_entity').id, + ), + ); + + i5.$$RemoteAssetEntityTableProcessedTableManager get assetId { + final $_column = $_itemColumn('asset_id')!; + + final manager = i5 + .$$RemoteAssetEntityTableTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('remote_asset_entity'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$AssetEditEntityTableFilterComposer + extends i0.Composer { + $$AssetEditEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters + get action => $composableBuilder( + column: $table.action, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + Map, + Map, + i3.Uint8List + > + get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get sequence => $composableBuilder( + column: $table.sequence, + builder: (column) => i0.ColumnFilters(column), + ); + + i5.$$RemoteAssetEntityTableFilterComposer get assetId { + final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableOrderingComposer + extends i0.Composer { + $$AssetEditEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get action => $composableBuilder( + column: $table.action, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get sequence => $composableBuilder( + column: $table.sequence, + builder: (column) => i0.ColumnOrderings(column), + ); + + i5.$$RemoteAssetEntityTableOrderingComposer get assetId { + final i5.$$RemoteAssetEntityTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableOrderingComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableAnnotationComposer + extends i0.Composer { + $$AssetEditEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get action => + $composableBuilder(column: $table.action, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter, i3.Uint8List> + get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => column, + ); + + i0.GeneratedColumn get sequence => + $composableBuilder(column: $table.sequence, builder: (column) => column); + + i5.$$RemoteAssetEntityTableAnnotationComposer get assetId { + final i5.$$RemoteAssetEntityTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData, + i1.$$AssetEditEntityTableFilterComposer, + i1.$$AssetEditEntityTableOrderingComposer, + i1.$$AssetEditEntityTableAnnotationComposer, + $$AssetEditEntityTableCreateCompanionBuilder, + $$AssetEditEntityTableUpdateCompanionBuilder, + (i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences), + i1.AssetEditEntityData, + i0.PrefetchHooks Function({bool assetId}) + > { + $$AssetEditEntityTableTableManager( + i0.GeneratedDatabase db, + i1.$AssetEditEntityTable table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => i1 + .$$AssetEditEntityTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value assetId = const i0.Value.absent(), + i0.Value action = const i0.Value.absent(), + i0.Value> parameters = + const i0.Value.absent(), + i0.Value sequence = const i0.Value.absent(), + }) => i1.AssetEditEntityCompanion( + id: id, + assetId: assetId, + action: action, + parameters: parameters, + sequence: sequence, + ), + createCompanionCallback: + ({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }) => i1.AssetEditEntityCompanion.insert( + id: id, + assetId: assetId, + action: action, + parameters: parameters, + sequence: sequence, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i1.$$AssetEditEntityTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({assetId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (assetId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.assetId, + referencedTable: i1 + .$$AssetEditEntityTableReferences + ._assetIdTable(db), + referencedColumn: i1 + .$$AssetEditEntityTableReferences + ._assetIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$AssetEditEntityTableProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData, + i1.$$AssetEditEntityTableFilterComposer, + i1.$$AssetEditEntityTableOrderingComposer, + i1.$$AssetEditEntityTableAnnotationComposer, + $$AssetEditEntityTableCreateCompanionBuilder, + $$AssetEditEntityTableUpdateCompanionBuilder, + (i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences), + i1.AssetEditEntityData, + i0.PrefetchHooks Function({bool assetId}) + >; +i0.Index get idxAssetEditAssetId => i0.Index( + 'idx_asset_edit_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)', +); + +class $AssetEditEntityTable extends i4.AssetEditEntity + with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $AssetEditEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + ); + static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta( + 'assetId', + ); + @override + late final i0.GeneratedColumn assetId = i0.GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + @override + late final i0.GeneratedColumnWithTypeConverter + action = + i0.GeneratedColumn( + 'action', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + ).withConverter( + i1.$AssetEditEntityTable.$converteraction, + ); + @override + late final i0.GeneratedColumnWithTypeConverter< + Map, + i3.Uint8List + > + parameters = + i0.GeneratedColumn( + 'parameters', + aliasedName, + false, + type: i0.DriftSqlType.blob, + requiredDuringInsert: true, + ).withConverter>( + i1.$AssetEditEntityTable.$converterparameters, + ); + static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta( + 'sequence', + ); + @override + late final i0.GeneratedColumn sequence = i0.GeneratedColumn( + 'sequence', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + assetId, + action, + parameters, + sequence, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_edit_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, { + bool isInserting = false, + }) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('asset_id')) { + context.handle( + _assetIdMeta, + assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta), + ); + } else if (isInserting) { + context.missing(_assetIdMeta); + } + if (data.containsKey('sequence')) { + context.handle( + _sequenceMeta, + sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta), + ); + } else if (isInserting) { + context.missing(_sequenceMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + i1.AssetEditEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.AssetEditEntityData( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + action: i1.$AssetEditEntityTable.$converteraction.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}action'], + )!, + ), + parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.blob, + data['${effectivePrefix}parameters'], + )!, + ), + sequence: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}sequence'], + )!, + ); + } + + @override + $AssetEditEntityTable createAlias(String alias) { + return $AssetEditEntityTable(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 $converteraction = + const i0.EnumIndexConverter( + i2.AssetEditAction.values, + ); + static i0.JsonTypeConverter2, i3.Uint8List, Object?> + $converterparameters = i4.editParameterConverter; + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetEditEntityData extends i0.DataClass + implements i0.Insertable { + final String id; + final String assetId; + final i2.AssetEditAction action; + final Map parameters; + final int sequence; + const AssetEditEntityData({ + required this.id, + required this.assetId, + required this.action, + required this.parameters, + required this.sequence, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['asset_id'] = i0.Variable(assetId); + { + map['action'] = i0.Variable( + i1.$AssetEditEntityTable.$converteraction.toSql(action), + ); + } + { + map['parameters'] = i0.Variable( + i1.$AssetEditEntityTable.$converterparameters.toSql(parameters), + ); + } + map['sequence'] = i0.Variable(sequence); + return map; + } + + factory AssetEditEntityData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return AssetEditEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + action: i1.$AssetEditEntityTable.$converteraction.fromJson( + serializer.fromJson(json['action']), + ), + parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson( + serializer.fromJson(json['parameters']), + ), + sequence: serializer.fromJson(json['sequence']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'action': serializer.toJson( + i1.$AssetEditEntityTable.$converteraction.toJson(action), + ), + 'parameters': serializer.toJson( + i1.$AssetEditEntityTable.$converterparameters.toJson(parameters), + ), + 'sequence': serializer.toJson(sequence), + }; + } + + i1.AssetEditEntityData copyWith({ + String? id, + String? assetId, + i2.AssetEditAction? action, + Map? parameters, + int? sequence, + }) => i1.AssetEditEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) { + return AssetEditEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + action: data.action.present ? data.action.value : this.action, + parameters: data.parameters.present + ? data.parameters.value + : this.parameters, + sequence: data.sequence.present ? data.sequence.value : this.sequence, + ); + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, assetId, action, parameters, sequence); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.AssetEditEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.action == this.action && + other.parameters == this.parameters && + other.sequence == this.sequence); +} + +class AssetEditEntityCompanion + extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value assetId; + final i0.Value action; + final i0.Value> parameters; + final i0.Value sequence; + const AssetEditEntityCompanion({ + this.id = const i0.Value.absent(), + this.assetId = const i0.Value.absent(), + this.action = const i0.Value.absent(), + this.parameters = const i0.Value.absent(), + this.sequence = const i0.Value.absent(), + }); + AssetEditEntityCompanion.insert({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }) : id = i0.Value(id), + assetId = i0.Value(assetId), + action = i0.Value(action), + parameters = i0.Value(parameters), + sequence = i0.Value(sequence); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? assetId, + i0.Expression? action, + i0.Expression? parameters, + i0.Expression? sequence, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (action != null) 'action': action, + if (parameters != null) 'parameters': parameters, + if (sequence != null) 'sequence': sequence, + }); + } + + i1.AssetEditEntityCompanion copyWith({ + i0.Value? id, + i0.Value? assetId, + i0.Value? action, + i0.Value>? parameters, + i0.Value? sequence, + }) { + return i1.AssetEditEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = i0.Variable(assetId.value); + } + if (action.present) { + map['action'] = i0.Variable( + i1.$AssetEditEntityTable.$converteraction.toSql(action.value), + ); + } + if (parameters.present) { + map['parameters'] = i0.Variable( + i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value), + ); + } + if (sequence.present) { + map['sequence'] = i0.Variable(sequence.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index 9d154a5013..e1cb5f5597 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -25,6 +25,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { RealColumn get longitude => real().nullable()(); + IntColumn get playbackStyle => intEnum().withDefault(const Constant(0))(); + @override Set get primaryKey => {id}; } @@ -43,6 +45,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData { width: width, remoteId: remoteId, orientation: orientation, + playbackStyle: playbackStyle, adjustmentTime: adjustmentTime, latitude: latitude, longitude: longitude, diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart index 088cfac97d..92ac3d2e35 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart @@ -25,6 +25,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder = i0.Value adjustmentTime, i0.Value latitude, i0.Value longitude, + i0.Value playbackStyle, }); typedef $$LocalAssetEntityTableUpdateCompanionBuilder = i1.LocalAssetEntityCompanion Function({ @@ -43,6 +44,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder = i0.Value adjustmentTime, i0.Value latitude, i0.Value longitude, + i0.Value playbackStyle, }); class $$LocalAssetEntityTableFilterComposer @@ -129,6 +131,16 @@ class $$LocalAssetEntityTableFilterComposer column: $table.longitude, builder: (column) => i0.ColumnFilters(column), ); + + i0.ColumnWithTypeConverterFilters< + i2.AssetPlaybackStyle, + i2.AssetPlaybackStyle, + int + > + get playbackStyle => $composableBuilder( + column: $table.playbackStyle, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); } class $$LocalAssetEntityTableOrderingComposer @@ -214,6 +226,11 @@ class $$LocalAssetEntityTableOrderingComposer column: $table.longitude, builder: (column) => i0.ColumnOrderings(column), ); + + i0.ColumnOrderings get playbackStyle => $composableBuilder( + column: $table.playbackStyle, + builder: (column) => i0.ColumnOrderings(column), + ); } class $$LocalAssetEntityTableAnnotationComposer @@ -277,6 +294,12 @@ class $$LocalAssetEntityTableAnnotationComposer i0.GeneratedColumn get longitude => $composableBuilder(column: $table.longitude, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter + get playbackStyle => $composableBuilder( + column: $table.playbackStyle, + builder: (column) => column, + ); } class $$LocalAssetEntityTableTableManager @@ -334,6 +357,8 @@ class $$LocalAssetEntityTableTableManager i0.Value adjustmentTime = const i0.Value.absent(), i0.Value latitude = const i0.Value.absent(), i0.Value longitude = const i0.Value.absent(), + i0.Value playbackStyle = + const i0.Value.absent(), }) => i1.LocalAssetEntityCompanion( name: name, type: type, @@ -350,6 +375,7 @@ class $$LocalAssetEntityTableTableManager adjustmentTime: adjustmentTime, latitude: latitude, longitude: longitude, + playbackStyle: playbackStyle, ), createCompanionCallback: ({ @@ -368,6 +394,8 @@ class $$LocalAssetEntityTableTableManager i0.Value adjustmentTime = const i0.Value.absent(), i0.Value latitude = const i0.Value.absent(), i0.Value longitude = const i0.Value.absent(), + i0.Value playbackStyle = + const i0.Value.absent(), }) => i1.LocalAssetEntityCompanion.insert( name: name, type: type, @@ -384,6 +412,7 @@ class $$LocalAssetEntityTableTableManager adjustmentTime: adjustmentTime, latitude: latitude, longitude: longitude, + playbackStyle: playbackStyle, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) @@ -596,6 +625,19 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity requiredDuringInsert: false, ); @override + late final i0.GeneratedColumnWithTypeConverter + playbackStyle = + i0.GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const i4.Constant(0), + ).withConverter( + i1.$LocalAssetEntityTable.$converterplaybackStyle, + ); + @override List get $columns => [ name, type, @@ -612,6 +654,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity adjustmentTime, latitude, longitude, + playbackStyle, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -793,6 +836,12 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity i0.DriftSqlType.double, data['${effectivePrefix}longitude'], ), + playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, + ), ); } @@ -803,6 +852,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity static i0.JsonTypeConverter2 $convertertype = const i0.EnumIndexConverter(i2.AssetType.values); + static i0.JsonTypeConverter2 + $converterplaybackStyle = const i0.EnumIndexConverter( + i2.AssetPlaybackStyle.values, + ); @override bool get withoutRowId => true; @override @@ -826,6 +879,7 @@ class LocalAssetEntityData extends i0.DataClass final DateTime? adjustmentTime; final double? latitude; final double? longitude; + final i2.AssetPlaybackStyle playbackStyle; const LocalAssetEntityData({ required this.name, required this.type, @@ -842,6 +896,7 @@ class LocalAssetEntityData extends i0.DataClass this.adjustmentTime, this.latitude, this.longitude, + required this.playbackStyle, }); @override Map toColumns(bool nullToAbsent) { @@ -881,6 +936,11 @@ class LocalAssetEntityData extends i0.DataClass if (!nullToAbsent || longitude != null) { map['longitude'] = i0.Variable(longitude); } + { + map['playback_style'] = i0.Variable( + i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle), + ); + } return map; } @@ -907,6 +967,9 @@ class LocalAssetEntityData extends i0.DataClass adjustmentTime: serializer.fromJson(json['adjustmentTime']), latitude: serializer.fromJson(json['latitude']), longitude: serializer.fromJson(json['longitude']), + playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson( + serializer.fromJson(json['playbackStyle']), + ), ); } @override @@ -930,6 +993,9 @@ class LocalAssetEntityData extends i0.DataClass 'adjustmentTime': serializer.toJson(adjustmentTime), 'latitude': serializer.toJson(latitude), 'longitude': serializer.toJson(longitude), + 'playbackStyle': serializer.toJson( + i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle), + ), }; } @@ -949,6 +1015,7 @@ class LocalAssetEntityData extends i0.DataClass i0.Value adjustmentTime = const i0.Value.absent(), i0.Value latitude = const i0.Value.absent(), i0.Value longitude = const i0.Value.absent(), + i2.AssetPlaybackStyle? playbackStyle, }) => i1.LocalAssetEntityData( name: name ?? this.name, type: type ?? this.type, @@ -969,6 +1036,7 @@ class LocalAssetEntityData extends i0.DataClass : this.adjustmentTime, latitude: latitude.present ? latitude.value : this.latitude, longitude: longitude.present ? longitude.value : this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, ); LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) { return LocalAssetEntityData( @@ -995,6 +1063,9 @@ class LocalAssetEntityData extends i0.DataClass : this.adjustmentTime, latitude: data.latitude.present ? data.latitude.value : this.latitude, longitude: data.longitude.present ? data.longitude.value : this.longitude, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, ); } @@ -1015,7 +1086,8 @@ class LocalAssetEntityData extends i0.DataClass ..write('iCloudId: $iCloudId, ') ..write('adjustmentTime: $adjustmentTime, ') ..write('latitude: $latitude, ') - ..write('longitude: $longitude') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } @@ -1037,6 +1109,7 @@ class LocalAssetEntityData extends i0.DataClass adjustmentTime, latitude, longitude, + playbackStyle, ); @override bool operator ==(Object other) => @@ -1056,7 +1129,8 @@ class LocalAssetEntityData extends i0.DataClass other.iCloudId == this.iCloudId && other.adjustmentTime == this.adjustmentTime && other.latitude == this.latitude && - other.longitude == this.longitude); + other.longitude == this.longitude && + other.playbackStyle == this.playbackStyle); } class LocalAssetEntityCompanion @@ -1076,6 +1150,7 @@ class LocalAssetEntityCompanion final i0.Value adjustmentTime; final i0.Value latitude; final i0.Value longitude; + final i0.Value playbackStyle; const LocalAssetEntityCompanion({ this.name = const i0.Value.absent(), this.type = const i0.Value.absent(), @@ -1092,6 +1167,7 @@ class LocalAssetEntityCompanion this.adjustmentTime = const i0.Value.absent(), this.latitude = const i0.Value.absent(), this.longitude = const i0.Value.absent(), + this.playbackStyle = const i0.Value.absent(), }); LocalAssetEntityCompanion.insert({ required String name, @@ -1109,6 +1185,7 @@ class LocalAssetEntityCompanion this.adjustmentTime = const i0.Value.absent(), this.latitude = const i0.Value.absent(), this.longitude = const i0.Value.absent(), + this.playbackStyle = const i0.Value.absent(), }) : name = i0.Value(name), type = i0.Value(type), id = i0.Value(id); @@ -1128,6 +1205,7 @@ class LocalAssetEntityCompanion i0.Expression? adjustmentTime, i0.Expression? latitude, i0.Expression? longitude, + i0.Expression? playbackStyle, }) { return i0.RawValuesInsertable({ if (name != null) 'name': name, @@ -1145,6 +1223,7 @@ class LocalAssetEntityCompanion if (adjustmentTime != null) 'adjustment_time': adjustmentTime, if (latitude != null) 'latitude': latitude, if (longitude != null) 'longitude': longitude, + if (playbackStyle != null) 'playback_style': playbackStyle, }); } @@ -1164,6 +1243,7 @@ class LocalAssetEntityCompanion i0.Value? adjustmentTime, i0.Value? latitude, i0.Value? longitude, + i0.Value? playbackStyle, }) { return i1.LocalAssetEntityCompanion( name: name ?? this.name, @@ -1181,6 +1261,7 @@ class LocalAssetEntityCompanion adjustmentTime: adjustmentTime ?? this.adjustmentTime, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, ); } @@ -1234,6 +1315,13 @@ class LocalAssetEntityCompanion if (longitude.present) { map['longitude'] = i0.Variable(longitude.value); } + if (playbackStyle.present) { + map['playback_style'] = i0.Variable( + i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql( + playbackStyle.value, + ), + ); + } return map; } @@ -1254,7 +1342,8 @@ class LocalAssetEntityCompanion ..write('iCloudId: $iCloudId, ') ..write('adjustmentTime: $adjustmentTime, ') ..write('latitude: $latitude, ') - ..write('longitude: $longitude') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index 2e08b6424a..73276d1756 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -26,7 +26,8 @@ SELECT NULL as latitude, NULL as longitude, NULL as adjustmentTime, - rae.is_edited + rae.is_edited, + 0 as playback_style FROM remote_asset_entity rae LEFT JOIN @@ -63,7 +64,8 @@ SELECT lae.latitude, lae.longitude, lae.adjustment_time, - 0 as is_edited + 0 as is_edited, + lae.playback_style FROM local_asset_entity lae WHERE NOT EXISTS ( diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index e88d11d442..c6004eb10d 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor { ); $arrayStartIndex += generatedlimit.amountOfVariables; return customSelect( - 'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}', + 'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}', variables: [ for (var $ in userIds) i0.Variable($), ...generatedlimit.introducedVariables, @@ -67,6 +67,7 @@ class MergedAssetDrift extends i1.ModularAccessor { longitude: row.readNullable('longitude'), adjustmentTime: row.readNullable('adjustmentTime'), isEdited: row.read('is_edited'), + playbackStyle: row.read('playback_style'), ), ); } @@ -139,6 +140,7 @@ class MergedAssetResult { final double? longitude; final DateTime? adjustmentTime; final bool isEdited; + final int playbackStyle; MergedAssetResult({ this.remoteId, this.localId, @@ -161,6 +163,7 @@ class MergedAssetResult { this.longitude, this.adjustmentTime, required this.isEdited, + required this.playbackStyle, }); } diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart index d239588529..4a8a374f20 100644 --- a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart @@ -28,6 +28,8 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity IntColumn get source => intEnum()(); + IntColumn get playbackStyle => intEnum().withDefault(const Constant(0))(); + @override Set get primaryKey => {id, albumId}; } @@ -45,6 +47,7 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD height: height, width: width, orientation: orientation, + playbackStyle: playbackStyle, isEdited: false, ); } diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart index eeec2b3019..84be6289b8 100644 --- a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart @@ -23,6 +23,7 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder = i0.Value isFavorite, i0.Value orientation, required i3.TrashOrigin source, + i0.Value playbackStyle, }); typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder = i1.TrashedLocalAssetEntityCompanion Function({ @@ -39,6 +40,7 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder = i0.Value isFavorite, i0.Value orientation, i0.Value source, + i0.Value playbackStyle, }); class $$TrashedLocalAssetEntityTableFilterComposer @@ -117,6 +119,16 @@ class $$TrashedLocalAssetEntityTableFilterComposer column: $table.source, builder: (column) => i0.ColumnWithTypeConverterFilters(column), ); + + i0.ColumnWithTypeConverterFilters< + i2.AssetPlaybackStyle, + i2.AssetPlaybackStyle, + int + > + get playbackStyle => $composableBuilder( + column: $table.playbackStyle, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); } class $$TrashedLocalAssetEntityTableOrderingComposer @@ -193,6 +205,11 @@ class $$TrashedLocalAssetEntityTableOrderingComposer column: $table.source, builder: (column) => i0.ColumnOrderings(column), ); + + i0.ColumnOrderings get playbackStyle => $composableBuilder( + column: $table.playbackStyle, + builder: (column) => i0.ColumnOrderings(column), + ); } class $$TrashedLocalAssetEntityTableAnnotationComposer @@ -249,6 +266,12 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer i0.GeneratedColumnWithTypeConverter get source => $composableBuilder(column: $table.source, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter + get playbackStyle => $composableBuilder( + column: $table.playbackStyle, + builder: (column) => column, + ); } class $$TrashedLocalAssetEntityTableTableManager @@ -310,6 +333,8 @@ class $$TrashedLocalAssetEntityTableTableManager i0.Value isFavorite = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), i0.Value source = const i0.Value.absent(), + i0.Value playbackStyle = + const i0.Value.absent(), }) => i1.TrashedLocalAssetEntityCompanion( name: name, type: type, @@ -324,6 +349,7 @@ class $$TrashedLocalAssetEntityTableTableManager isFavorite: isFavorite, orientation: orientation, source: source, + playbackStyle: playbackStyle, ), createCompanionCallback: ({ @@ -340,6 +366,8 @@ class $$TrashedLocalAssetEntityTableTableManager i0.Value isFavorite = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), required i3.TrashOrigin source, + i0.Value playbackStyle = + const i0.Value.absent(), }) => i1.TrashedLocalAssetEntityCompanion.insert( name: name, type: type, @@ -354,6 +382,7 @@ class $$TrashedLocalAssetEntityTableTableManager isFavorite: isFavorite, orientation: orientation, source: source, + playbackStyle: playbackStyle, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) @@ -550,6 +579,19 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity i1.$TrashedLocalAssetEntityTable.$convertersource, ); @override + late final i0.GeneratedColumnWithTypeConverter + playbackStyle = + i0.GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const i4.Constant(0), + ).withConverter( + i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle, + ); + @override List get $columns => [ name, type, @@ -564,6 +606,7 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity isFavorite, orientation, source, + playbackStyle, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -720,6 +763,13 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity data['${effectivePrefix}source'], )!, ), + playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle + .fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, + ), ); } @@ -732,6 +782,10 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity const i0.EnumIndexConverter(i2.AssetType.values); static i0.JsonTypeConverter2 $convertersource = const i0.EnumIndexConverter(i3.TrashOrigin.values); + static i0.JsonTypeConverter2 + $converterplaybackStyle = const i0.EnumIndexConverter( + i2.AssetPlaybackStyle.values, + ); @override bool get withoutRowId => true; @override @@ -753,6 +807,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass final bool isFavorite; final int orientation; final i3.TrashOrigin source; + final i2.AssetPlaybackStyle playbackStyle; const TrashedLocalAssetEntityData({ required this.name, required this.type, @@ -767,6 +822,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass required this.isFavorite, required this.orientation, required this.source, + required this.playbackStyle, }); @override Map toColumns(bool nullToAbsent) { @@ -800,6 +856,13 @@ class TrashedLocalAssetEntityData extends i0.DataClass i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source), ); } + { + map['playback_style'] = i0.Variable( + i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql( + playbackStyle, + ), + ); + } return map; } @@ -826,6 +889,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson( serializer.fromJson(json['source']), ), + playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle + .fromJson(serializer.fromJson(json['playbackStyle'])), ); } @override @@ -849,6 +914,11 @@ class TrashedLocalAssetEntityData extends i0.DataClass 'source': serializer.toJson( i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source), ), + 'playbackStyle': serializer.toJson( + i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toJson( + playbackStyle, + ), + ), }; } @@ -866,6 +936,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass bool? isFavorite, int? orientation, i3.TrashOrigin? source, + i2.AssetPlaybackStyle? playbackStyle, }) => i1.TrashedLocalAssetEntityData( name: name ?? this.name, type: type ?? this.type, @@ -882,6 +953,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass isFavorite: isFavorite ?? this.isFavorite, orientation: orientation ?? this.orientation, source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, ); TrashedLocalAssetEntityData copyWithCompanion( i1.TrashedLocalAssetEntityCompanion data, @@ -906,6 +978,9 @@ class TrashedLocalAssetEntityData extends i0.DataClass ? data.orientation.value : this.orientation, source: data.source.present ? data.source.value : this.source, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, ); } @@ -924,7 +999,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass ..write('checksum: $checksum, ') ..write('isFavorite: $isFavorite, ') ..write('orientation: $orientation, ') - ..write('source: $source') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } @@ -944,6 +1020,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass isFavorite, orientation, source, + playbackStyle, ); @override bool operator ==(Object other) => @@ -961,7 +1038,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass other.checksum == this.checksum && other.isFavorite == this.isFavorite && other.orientation == this.orientation && - other.source == this.source); + other.source == this.source && + other.playbackStyle == this.playbackStyle); } class TrashedLocalAssetEntityCompanion @@ -979,6 +1057,7 @@ class TrashedLocalAssetEntityCompanion final i0.Value isFavorite; final i0.Value orientation; final i0.Value source; + final i0.Value playbackStyle; const TrashedLocalAssetEntityCompanion({ this.name = const i0.Value.absent(), this.type = const i0.Value.absent(), @@ -993,6 +1072,7 @@ class TrashedLocalAssetEntityCompanion this.isFavorite = const i0.Value.absent(), this.orientation = const i0.Value.absent(), this.source = const i0.Value.absent(), + this.playbackStyle = const i0.Value.absent(), }); TrashedLocalAssetEntityCompanion.insert({ required String name, @@ -1008,6 +1088,7 @@ class TrashedLocalAssetEntityCompanion this.isFavorite = const i0.Value.absent(), this.orientation = const i0.Value.absent(), required i3.TrashOrigin source, + this.playbackStyle = const i0.Value.absent(), }) : name = i0.Value(name), type = i0.Value(type), id = i0.Value(id), @@ -1027,6 +1108,7 @@ class TrashedLocalAssetEntityCompanion i0.Expression? isFavorite, i0.Expression? orientation, i0.Expression? source, + i0.Expression? playbackStyle, }) { return i0.RawValuesInsertable({ if (name != null) 'name': name, @@ -1042,6 +1124,7 @@ class TrashedLocalAssetEntityCompanion if (isFavorite != null) 'is_favorite': isFavorite, if (orientation != null) 'orientation': orientation, if (source != null) 'source': source, + if (playbackStyle != null) 'playback_style': playbackStyle, }); } @@ -1059,6 +1142,7 @@ class TrashedLocalAssetEntityCompanion i0.Value? isFavorite, i0.Value? orientation, i0.Value? source, + i0.Value? playbackStyle, }) { return i1.TrashedLocalAssetEntityCompanion( name: name ?? this.name, @@ -1074,6 +1158,7 @@ class TrashedLocalAssetEntityCompanion isFavorite: isFavorite ?? this.isFavorite, orientation: orientation ?? this.orientation, source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, ); } @@ -1123,6 +1208,13 @@ class TrashedLocalAssetEntityCompanion i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value), ); } + if (playbackStyle.present) { + map['playback_style'] = i0.Variable( + i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql( + playbackStyle.value, + ), + ); + } return map; } @@ -1141,7 +1233,8 @@ class TrashedLocalAssetEntityCompanion ..write('checksum: $checksum, ') ..write('isFavorite: $isFavorite, ') ..write('orientation: $orientation, ') - ..write('source: $source') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index 5be7b57835..4df470277e 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -24,6 +24,8 @@ abstract class ImageRequest { Future load(ImageDecoderCallback decode, {double scale = 1.0}); + Future loadCodec(); + void cancel() { if (_isCancelled) { return; @@ -34,7 +36,7 @@ abstract class ImageRequest { void _onCancelled(); - Future _fromEncodedPlatformImage(int address, int length) async { + Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async { final pointer = Pointer.fromAddress(address); if (_isCancelled) { malloc.free(pointer); @@ -67,6 +69,20 @@ abstract class ImageRequest { return null; } + return (codec, descriptor); + } + + Future _fromEncodedPlatformImage(int address, int length) async { + final result = await _codecFromEncodedPlatformImage(address, length); + if (result == null) return null; + + final (codec, descriptor) = result; + if (_isCancelled) { + descriptor.dispose(); + codec.dispose(); + return null; + } + final frame = await codec.getNextFrame(); descriptor.dispose(); codec.dispose(); diff --git a/mobile/lib/infrastructure/loaders/local_image_request.dart b/mobile/lib/infrastructure/loaders/local_image_request.dart index c2e3165aad..a6c9fa2989 100644 --- a/mobile/lib/infrastructure/loaders/local_image_request.dart +++ b/mobile/lib/infrastructure/loaders/local_image_request.dart @@ -22,6 +22,7 @@ class LocalImageRequest extends ImageRequest { width: width, height: height, isVideo: assetType == AssetType.video, + preferEncoded: false, ); if (info == null) { return null; @@ -31,6 +32,26 @@ class LocalImageRequest extends ImageRequest { return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } + @override + Future loadCodec() async { + if (_isCancelled) { + return null; + } + + final info = await localImageApi.requestImage( + localId, + requestId: requestId, + width: width, + height: height, + isVideo: assetType == AssetType.video, + preferEncoded: true, + ); + if (info == null) return null; + + final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); + return codec; + } + @override Future _onCancelled() { return localImageApi.cancelRequest(requestId); diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 2da70c3ae1..bcfa9a93c7 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -2,9 +2,8 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { final String uri; - final Map headers; - RemoteImageRequest({required this.uri, required this.headers}); + RemoteImageRequest({required this.uri}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -12,7 +11,8 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId); + final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false); + // Android always returns encoded data, so we need to check for both shapes of the response. final frame = switch (info) { {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} => @@ -22,6 +22,19 @@ class RemoteImageRequest extends ImageRequest { return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } + @override + Future loadCodec() async { + if (_isCancelled) { + return null; + } + + final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true); + if (info == null) return null; + + final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); + return codec; + } + @override Future _onCancelled() { return remoteImageApi.cancelRequest(requestId); diff --git a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart index 2ced28b810..61e6a1b3ad 100644 --- a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart +++ b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart @@ -16,6 +16,9 @@ class ThumbhashImageRequest extends ImageRequest { return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } + @override + Future loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading'); + @override void _onCancelled() {} } diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 3d19c43aa7..7c8571dc71 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart'; @@ -25,11 +26,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:isar/isar.dart' hide Index; -import 'db.repository.drift.dart'; - // #zoneTxn is the symbol used by Isar to mark a transaction within the current zone // ref: isar/isar_common.dart const Symbol _kzoneTxn = #zoneTxn; @@ -67,6 +67,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { AssetFaceEntity, StoreEntity, TrashedLocalAssetEntity, + AssetEditEntity, AssetOcrEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, @@ -99,7 +100,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 21; + int get schemaVersion => 23; @override MigrationStrategy get migration => MigrationStrategy( @@ -233,7 +234,15 @@ class Drift extends $Drift implements IDatabaseRepository { await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt); }, from20To21: (m, v21) async { - await m.create(v21.assetOcrEntity); + await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle); + await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle); + }, + from21To22: (m, v22) async { + await m.createTable(v22.assetEditEntity); + await m.createIndex(v22.idxAssetEditAssetId); + }, + from22To23: (m, v23) async { + await m.create(v23.assetOcrEntity); }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 47a7d7ff44..c898b7ce65 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -41,7 +41,7 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart' as i19; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' as i20; -import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart' +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart' as i21; import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' as i22; @@ -87,9 +87,8 @@ abstract class $Drift extends i0.GeneratedDatabase { late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this); late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20 .$TrashedLocalAssetEntityTable(this); - late final i21.$AssetOcrEntityTable assetOcrEntity = i21.$AssetOcrEntityTable( - this, - ); + late final i21.$AssetEditEntityTable assetEditEntity = i21 + .$AssetEditEntityTable(this); i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer( this, ).accessor(i22.MergedAssetDrift.new); @@ -130,7 +129,7 @@ abstract class $Drift extends i0.GeneratedDatabase { assetFaceEntity, storeEntity, trashedLocalAssetEntity, - assetOcrEntity, + assetEditEntity, i10.idxPartnerSharedWithId, i11.idxLatLng, i12.idxRemoteAlbumAssetAlbumAsset, @@ -140,6 +139,7 @@ abstract class $Drift extends i0.GeneratedDatabase { i18.idxAssetFaceAssetId, i20.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetAlbum, + i21.idxAssetEditAssetId, ]; @override i0.StreamQueryUpdateRules @@ -336,7 +336,7 @@ abstract class $Drift extends i0.GeneratedDatabase { 'remote_asset_entity', limitUpdateKind: i0.UpdateKind.delete, ), - result: [i0.TableUpdate('asset_ocr_entity', kind: i0.UpdateKind.delete)], + result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)], ), ]); @override @@ -397,6 +397,6 @@ class $DriftManager { _db, _db.trashedLocalAssetEntity, ); - i21.$$AssetOcrEntityTableTableManager get assetOcrEntity => - i21.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity); + i21.$$AssetEditEntityTableTableManager get assetEditEntity => + i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 3a2db6c0af..379f37169d 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -8941,7 +8941,6 @@ final class Schema21 extends i0.VersionedSchema { assetFaceEntity, storeEntity, trashedLocalAssetEntity, - assetOcrEntity, idxPartnerSharedWithId, idxLatLng, idxRemoteAlbumAssetAlbumAsset, @@ -9012,7 +9011,7 @@ final class Schema21 extends i0.VersionedSchema { ), alias: null, ); - late final Shape26 localAssetEntity = Shape26( + late final Shape30 localAssetEntity = Shape30( source: i0.VersionedTable( entityName: 'local_asset_entity', withoutRowId: true, @@ -9034,6 +9033,7 @@ final class Schema21 extends i0.VersionedSchema { _column_96, _column_46, _column_47, + _column_103, ], attachedDatabase: database, ), @@ -9348,7 +9348,7 @@ final class Schema21 extends i0.VersionedSchema { ), alias: null, ); - late final Shape25 trashedLocalAssetEntity = Shape25( + late final Shape31 trashedLocalAssetEntity = Shape31( source: i0.VersionedTable( entityName: 'trashed_local_asset_entity', withoutRowId: true, @@ -9368,32 +9368,7 @@ final class Schema21 extends i0.VersionedSchema { _column_14, _column_23, _column_97, - ], - attachedDatabase: database, - ), - alias: null, - ); - late final Shape30 assetOcrEntity = Shape30( - source: i0.VersionedTable( - entityName: 'asset_ocr_entity', - withoutRowId: true, - isStrict: true, - tableConstraints: ['PRIMARY KEY(id)'], - columns: [ - _column_0, - _column_36, _column_103, - _column_104, - _column_105, - _column_106, - _column_107, - _column_108, - _column_109, - _column_110, - _column_111, - _column_112, - _column_113, - _column_102, ], attachedDatabase: database, ), @@ -9439,112 +9414,639 @@ final class Schema21 extends i0.VersionedSchema { class Shape30 extends i0.VersionedTable { Shape30({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get width => + columnsByName['width']! as i1.GeneratedColumn; + i1.GeneratedColumn get height => + columnsByName['height']! as i1.GeneratedColumn; + i1.GeneratedColumn get durationInSeconds => + columnsByName['duration_in_seconds']! as i1.GeneratedColumn; + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get checksum => + columnsByName['checksum']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; + i1.GeneratedColumn get orientation => + columnsByName['orientation']! as i1.GeneratedColumn; + i1.GeneratedColumn get iCloudId => + columnsByName['i_cloud_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get adjustmentTime => + columnsByName['adjustment_time']! as i1.GeneratedColumn; + i1.GeneratedColumn get latitude => + columnsByName['latitude']! as i1.GeneratedColumn; + i1.GeneratedColumn get longitude => + columnsByName['longitude']! as i1.GeneratedColumn; + i1.GeneratedColumn get playbackStyle => + columnsByName['playback_style']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_103(String aliasedName) => + i1.GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: i1.DriftSqlType.int, + defaultValue: const CustomExpression('0'), + ); + +class Shape31 extends i0.VersionedTable { + Shape31({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get width => + columnsByName['width']! as i1.GeneratedColumn; + i1.GeneratedColumn get height => + columnsByName['height']! as i1.GeneratedColumn; + i1.GeneratedColumn get durationInSeconds => + columnsByName['duration_in_seconds']! as i1.GeneratedColumn; + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get albumId => + columnsByName['album_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get checksum => + columnsByName['checksum']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; + i1.GeneratedColumn get orientation => + columnsByName['orientation']! as i1.GeneratedColumn; + i1.GeneratedColumn get source => + columnsByName['source']! as i1.GeneratedColumn; + i1.GeneratedColumn get playbackStyle => + columnsByName['playback_style']! as i1.GeneratedColumn; +} + +final class Schema22 extends i0.VersionedSchema { + Schema22({required super.database}) : super(version: 22); + @override + late final List entities = [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + assetEditEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + idxAssetEditAssetId, + ]; + late final Shape20 userEntity = Shape20( + source: i0.VersionedTable( + entityName: 'user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_3, + _column_84, + _column_85, + _column_91, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape28 remoteAssetEntity = Shape28( + source: i0.VersionedTable( + entityName: 'remote_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + _column_86, + _column_101, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 stackEntity = Shape3( + source: i0.VersionedTable( + entityName: 'stack_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_0, _column_9, _column_5, _column_15, _column_75], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape30 localAssetEntity = Shape30( + source: i0.VersionedTable( + entityName: 'local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_22, + _column_14, + _column_23, + _column_98, + _column_96, + _column_46, + _column_47, + _column_103, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape9 remoteAlbumEntity = Shape9( + source: i0.VersionedTable( + entityName: 'remote_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_56, + _column_9, + _column_5, + _column_15, + _column_57, + _column_58, + _column_59, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape19 localAlbumEntity = Shape19( + source: i0.VersionedTable( + entityName: 'local_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_5, + _column_31, + _column_32, + _column_90, + _column_33, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape22 localAlbumAssetEntity = Shape22( + source: i0.VersionedTable( + entityName: 'local_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, album_id)'], + columns: [_column_34, _column_35, _column_33], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + final i1.Index idxRemoteAlbumOwnerId = i1.Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + final i1.Index idxLocalAssetChecksum = i1.Index( + 'idx_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)', + ); + final i1.Index idxLocalAssetCloudId = i1.Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', + ); + final i1.Index idxStackPrimaryAssetId = i1.Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', + ); + final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( + 'idx_remote_asset_owner_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', + ); + final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', + ); + final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index( + 'UQ_remote_assets_owner_library_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', + ); + final i1.Index idxRemoteAssetChecksum = i1.Index( + 'idx_remote_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', + ); + final i1.Index idxRemoteAssetStackId = i1.Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + late final Shape21 authUserEntity = Shape21( + source: i0.VersionedTable( + entityName: 'auth_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_3, + _column_2, + _column_84, + _column_85, + _column_92, + _column_93, + _column_7, + _column_94, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 userMetadataEntity = Shape4( + source: i0.VersionedTable( + entityName: 'user_metadata_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(user_id, "key")'], + columns: [_column_25, _column_26, _column_27], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape5 partnerEntity = Shape5( + source: i0.VersionedTable( + entityName: 'partner_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'], + columns: [_column_28, _column_29, _column_30], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape8 remoteExifEntity = Shape8( + source: i0.VersionedTable( + entityName: 'remote_exif_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id)'], + columns: [ + _column_36, + _column_37, + _column_38, + _column_39, + _column_40, + _column_41, + _column_11, + _column_10, + _column_42, + _column_43, + _column_44, + _column_45, + _column_46, + _column_47, + _column_48, + _column_49, + _column_50, + _column_51, + _column_52, + _column_53, + _column_54, + _column_55, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape7 remoteAlbumAssetEntity = Shape7( + source: i0.VersionedTable( + entityName: 'remote_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, album_id)'], + columns: [_column_36, _column_60], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape10 remoteAlbumUserEntity = Shape10( + source: i0.VersionedTable( + entityName: 'remote_album_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(album_id, user_id)'], + columns: [_column_60, _column_25, _column_61], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape27 remoteAssetCloudIdEntity = Shape27( + source: i0.VersionedTable( + entityName: 'remote_asset_cloud_id_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id)'], + columns: [ + _column_36, + _column_99, + _column_100, + _column_96, + _column_46, + _column_47, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape11 memoryEntity = Shape11( + source: i0.VersionedTable( + entityName: 'memory_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_9, + _column_5, + _column_18, + _column_15, + _column_8, + _column_62, + _column_63, + _column_64, + _column_65, + _column_66, + _column_67, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape12 memoryAssetEntity = Shape12( + source: i0.VersionedTable( + entityName: 'memory_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'], + columns: [_column_36, _column_68], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape14 personEntity = Shape14( + source: i0.VersionedTable( + entityName: 'person_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_1, + _column_69, + _column_71, + _column_72, + _column_73, + _column_74, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape29 assetFaceEntity = Shape29( + source: i0.VersionedTable( + entityName: 'asset_face_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_36, + _column_76, + _column_77, + _column_78, + _column_79, + _column_80, + _column_81, + _column_82, + _column_83, + _column_102, + _column_18, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape18 storeEntity = Shape18( + source: i0.VersionedTable( + entityName: 'store_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_87, _column_88, _column_89], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape31 trashedLocalAssetEntity = Shape31( + source: i0.VersionedTable( + entityName: 'trashed_local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id, album_id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_95, + _column_22, + _column_14, + _column_23, + _column_97, + _column_103, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape32 assetEditEntity = Shape32( + source: i0.VersionedTable( + entityName: 'asset_edit_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_0, _column_36, _column_104, _column_105, _column_106], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxPartnerSharedWithId = i1.Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + final i1.Index idxLatLng = i1.Index( + 'idx_lat_lng', + 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', + ); + final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + final i1.Index idxRemoteAssetCloudId = i1.Index( + 'idx_remote_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)', + ); + final i1.Index idxPersonOwnerId = i1.Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + final i1.Index idxAssetFacePersonId = i1.Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + final i1.Index idxAssetFaceAssetId = i1.Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', + ); + final i1.Index idxTrashedLocalAssetChecksum = i1.Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', + ); + final i1.Index idxTrashedLocalAssetAlbum = i1.Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', + ); + final i1.Index idxAssetEditAssetId = i1.Index( + 'idx_asset_edit_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)', + ); +} + +class Shape32 extends i0.VersionedTable { + Shape32({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get id => columnsByName['id']! as i1.GeneratedColumn; i1.GeneratedColumn get assetId => columnsByName['asset_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get x1 => - columnsByName['x1']! as i1.GeneratedColumn; - i1.GeneratedColumn get y1 => - columnsByName['y1']! as i1.GeneratedColumn; - i1.GeneratedColumn get x2 => - columnsByName['x2']! as i1.GeneratedColumn; - i1.GeneratedColumn get y2 => - columnsByName['y2']! as i1.GeneratedColumn; - i1.GeneratedColumn get x3 => - columnsByName['x3']! as i1.GeneratedColumn; - i1.GeneratedColumn get y3 => - columnsByName['y3']! as i1.GeneratedColumn; - i1.GeneratedColumn get x4 => - columnsByName['x4']! as i1.GeneratedColumn; - i1.GeneratedColumn get y4 => - columnsByName['y4']! as i1.GeneratedColumn; - i1.GeneratedColumn get boxScore => - columnsByName['box_score']! as i1.GeneratedColumn; - i1.GeneratedColumn get textScore => - columnsByName['text_score']! as i1.GeneratedColumn; - i1.GeneratedColumn get recognizedText => - columnsByName['text']! as i1.GeneratedColumn; - i1.GeneratedColumn get isVisible => - columnsByName['is_visible']! as i1.GeneratedColumn; + i1.GeneratedColumn get action => + columnsByName['action']! as i1.GeneratedColumn; + i1.GeneratedColumn get parameters => + columnsByName['parameters']! as i1.GeneratedColumn; + i1.GeneratedColumn get sequence => + columnsByName['sequence']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_103(String aliasedName) => - i1.GeneratedColumn( - 'x1', +i1.GeneratedColumn _column_104(String aliasedName) => + i1.GeneratedColumn( + 'action', aliasedName, false, - type: i1.DriftSqlType.double, + type: i1.DriftSqlType.int, ); -i1.GeneratedColumn _column_104(String aliasedName) => - i1.GeneratedColumn( - 'y1', +i1.GeneratedColumn _column_105(String aliasedName) => + i1.GeneratedColumn( + 'parameters', aliasedName, false, - type: i1.DriftSqlType.double, + type: i1.DriftSqlType.blob, ); -i1.GeneratedColumn _column_105(String aliasedName) => - i1.GeneratedColumn( - 'x2', +i1.GeneratedColumn _column_106(String aliasedName) => + i1.GeneratedColumn( + 'sequence', aliasedName, false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_106(String aliasedName) => - i1.GeneratedColumn( - 'y2', - aliasedName, - false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_107(String aliasedName) => - i1.GeneratedColumn( - 'x3', - aliasedName, - false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_108(String aliasedName) => - i1.GeneratedColumn( - 'y3', - aliasedName, - false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_109(String aliasedName) => - i1.GeneratedColumn( - 'x4', - aliasedName, - false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_110(String aliasedName) => - i1.GeneratedColumn( - 'y4', - aliasedName, - false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_111(String aliasedName) => - i1.GeneratedColumn( - 'box_score', - aliasedName, - false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_112(String aliasedName) => - i1.GeneratedColumn( - 'text_score', - aliasedName, - false, - type: i1.DriftSqlType.double, - ); -i1.GeneratedColumn _column_113(String aliasedName) => - i1.GeneratedColumn( - 'text', - aliasedName, - false, - type: i1.DriftSqlType.string, + type: i1.DriftSqlType.int, ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, @@ -9567,6 +10069,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema19 schema) from18To19, required Future Function(i1.Migrator m, Schema20 schema) from19To20, required Future Function(i1.Migrator m, Schema21 schema) from20To21, + required Future Function(i1.Migrator m, Schema22 schema) from21To22, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -9670,6 +10173,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from20To21(migrator, schema); return 21; + case 21: + final schema = Schema22(database: database); + final migrator = i1.Migrator(database, schema); + await from21To22(migrator, schema); + return 22; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -9697,6 +10205,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema19 schema) from18To19, required Future Function(i1.Migrator m, Schema20 schema) from19To20, required Future Function(i1.Migrator m, Schema21 schema) from20To21, + required Future Function(i1.Migrator m, Schema22 schema) from21To22, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -9719,5 +10228,6 @@ i1.OnUpgrade stepByStep({ from18To19: from18To19, from19To20: from19To20, from20To21: from20To21, + from21To22: from21To22, ), ); diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index a59e200923..87a72f02be 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -301,6 +301,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { id: asset.id, orientation: Value(asset.orientation), isFavorite: Value(asset.isFavorite), + playbackStyle: Value(asset.playbackStyle), latitude: Value(asset.latitude), longitude: Value(asset.longitude), adjustmentTime: Value(asset.adjustmentTime), @@ -333,6 +334,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { checksum: const Value(null), orientation: Value(asset.orientation), isFavorite: Value(asset.isFavorite), + playbackStyle: Value(asset.playbackStyle), ); batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>( _db.localAssetEntity, diff --git a/mobile/lib/infrastructure/repositories/logger_db.repository.dart b/mobile/lib/infrastructure/repositories/logger_db.repository.dart index 0037f4a1e3..e494782fa6 100644 --- a/mobile/lib/infrastructure/repositories/logger_db.repository.dart +++ b/mobile/lib/infrastructure/repositories/logger_db.repository.dart @@ -2,8 +2,7 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; - -import 'logger_db.repository.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart'; @DriftDatabase(tables: [LogMessageEntity]) class DriftLogger extends $DriftLogger implements IDatabaseRepository { diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart index a73322cb5c..bb5796e220 100644 --- a/mobile/lib/infrastructure/repositories/network.repository.dart +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -1,67 +1,55 @@ +import 'dart:ffi'; import 'dart:io'; -import 'package:cronet_http/cronet_http.dart'; import 'package:cupertino_http/cupertino_http.dart'; import 'package:http/http.dart' as http; -import 'package:immich_mobile/utils/user_agent.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:ok_http/ok_http.dart'; +import 'package:web_socket/web_socket.dart'; class NetworkRepository { - static late Directory _cachePath; - static late String _userAgent; - static final _clients = {}; + static http.Client? _client; + static Pointer? _clientPointer; - static Future init() { - return ( - getTemporaryDirectory().then((cachePath) => _cachePath = cachePath), - getUserAgentString().then((userAgent) => _userAgent = userAgent), - ).wait; + static Future init() async { + final clientPointer = Pointer.fromAddress(await networkApi.getClientPointer()); + if (clientPointer == _clientPointer) { + return; + } + _clientPointer = clientPointer; + _client?.close(); + if (Platform.isIOS) { + final session = URLSession.fromRawPointer(clientPointer.cast()); + _client = CupertinoClient.fromSharedSession(session); + } else { + _client = OkHttpClient.fromJniGlobalRef(clientPointer); + } } - static void reset() { - Future.microtask(init); - for (final client in _clients.values) { - client.close(); + static Future setHeaders(Map headers, List serverUrls, {String? token}) async { + await networkApi.setRequestHeaders(headers, serverUrls, token); + if (Platform.isIOS) { + await init(); + } + } + + // ignore: avoid-unused-parameters + static Future createWebSocket(Uri uri, {Map? headers, Iterable? protocols}) { + if (Platform.isIOS) { + final session = URLSession.fromRawPointer(_clientPointer!.cast()); + return CupertinoWebSocket.connectWithSession(session, uri, protocols: protocols); + } else { + return OkHttpWebSocket.connectFromJniGlobalRef(_clientPointer!, uri, protocols: protocols); } - _clients.clear(); } const NetworkRepository(); - /// Note: when disk caching is enabled, only one client may use a given directory at a time. - /// Different isolates or engines must use different directories. - http.Client getHttpClient( - String directoryName, { - CacheMode cacheMode = CacheMode.memory, - int diskCapacity = 0, - int maxConnections = 6, - int memoryCapacity = 10 << 20, - }) { - final cachedClient = _clients[directoryName]; - if (cachedClient != null) { - return cachedClient; - } - - final directory = Directory('${_cachePath.path}/$directoryName'); - directory.createSync(recursive: true); - if (Platform.isAndroid) { - final engine = CronetEngine.build( - cacheMode: cacheMode, - cacheMaxSize: diskCapacity, - storagePath: directory.path, - userAgent: _userAgent, - ); - return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true); - } - - final config = URLSessionConfiguration.defaultSessionConfiguration() - ..httpMaximumConnectionsPerHost = maxConnections - ..cache = URLCache.withCapacity( - diskCapacity: diskCapacity, - memoryCapacity: memoryCapacity, - directory: directory.uri, - ) - ..httpAdditionalHeaders = {'User-Agent': _userAgent}; - return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config); - } + /// Returns a shared HTTP client that uses native SSL configuration. + /// + /// On iOS: Uses SharedURLSessionManager's URLSession. + /// On Android: Uses SharedHttpClientManager's OkHttpClient. + /// + /// Must call [init] before using this method. + static http.Client get client => _client!; } diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 1fa205a2ef..081805f853 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/semver.dart'; import 'package:logging/logging.dart'; @@ -32,15 +33,11 @@ class SyncApiRepository { http.Client? httpClient, }) async { final stopwatch = Stopwatch()..start(); - final client = httpClient ?? http.Client(); + final client = httpClient ?? NetworkRepository.client; final endpoint = "${_api.apiClient.basePath}/sync/stream"; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; - final headerParams = {}; - await _api.applyToParams([], headerParams); - headers.addAll(headerParams); - final shouldReset = Store.get(StoreKey.shouldResetSync, false); final request = http.Request('POST', Uri.parse(endpoint)); request.headers.addAll(headers); @@ -51,6 +48,7 @@ class SyncApiRepository { SyncRequestType.usersV1, SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, + if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1, SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, SyncRequestType.partnerAssetsV1, @@ -120,8 +118,6 @@ class SyncApiRepository { } } catch (error, stack) { return Future.error(error, stack); - } finally { - client.close(); } stopwatch.stop(); _logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms"); @@ -157,6 +153,8 @@ const _kResponseMap = { SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, + SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson, + SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson, SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 506a71f906..3a5df3f9e7 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -5,9 +5,11 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; @@ -27,8 +29,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; -import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; +import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction; +import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility; class SyncStreamRepository extends DriftDatabaseRepository { final Logger _logger = Logger('DriftSyncStreamRepository'); @@ -59,6 +61,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.userEntity.deleteAll(); await _db.userMetadataEntity.deleteAll(); await _db.remoteAssetCloudIdEntity.deleteAll(); + await _db.assetEditEntity.deleteAll(); await _db.assetOcrEntity.deleteAll(); }); await _db.customStatement('PRAGMA foreign_keys = ON'); @@ -324,6 +327,63 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future updateAssetEditsV1(Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + for (final edit in data) { + final companion = AssetEditEntityCompanion( + id: Value(edit.id), + assetId: Value(edit.assetId), + action: Value(edit.action.toAssetEditAction()), + parameters: Value(edit.parameters as Map), + sequence: Value(edit.sequence), + ); + + batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion)); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + + Future replaceAssetEditsV1(String assetId, Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId)); + + for (final edit in data) { + final companion = AssetEditEntityCompanion( + id: Value(edit.id), + assetId: Value(edit.assetId), + action: Value(edit.action.toAssetEditAction()), + parameters: Value(edit.parameters as Map), + sequence: Value(edit.sequence), + ); + + batch.insert(_db.assetEditEntity, companion); + } + }); + } catch (error, stack) { + _logger.severe('Error: replaceAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + + Future deleteAssetEditsV1(Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + for (final edit in data) { + batch.deleteWhere(_db.assetEditEntity, (row) => row.id.equals(edit.editId)); + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + Future deleteAlbumsV1(Iterable data) async { try { await _db.batch((batch) { @@ -847,3 +907,12 @@ extension on String { extension on UserAvatarColor { AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value); } + +extension on api.AssetEditAction { + AssetEditAction toAssetEditAction() => switch (this) { + api.AssetEditAction.crop => AssetEditAction.crop, + api.AssetEditAction.rotate => AssetEditAction.rotate, + api.AssetEditAction.mirror => AssetEditAction.mirror, + _ => AssetEditAction.other, + }; +} diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index e39dc10a8a..74af6dc3f0 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -101,6 +101,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds, orientation: row.orientation, + playbackStyle: AssetPlaybackStyle.values[row.playbackStyle], cloudId: row.iCloudId, latitude: row.latitude, longitude: row.longitude, @@ -275,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetStream(List Function() getAssets, Stream assetCount, TimelineOrigin origin) => + ( + bucketSource: () async* { + yield _generateBuckets(getAssets().length); + yield* assetCount.map(_generateBuckets); + }, + assetSource: (offset, count) { + final assets = getAssets(); + return Future.value(assets.skip(offset).take(count).toList(growable: false)); + }, + origin: origin, + ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { // Sort assets by date descending and group by day final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart index 7e93713c46..1195256f5e 100644 --- a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -85,6 +85,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { durationInSeconds: Value(item.asset.durationInSeconds), isFavorite: Value(item.asset.isFavorite), orientation: Value(item.asset.orientation), + playbackStyle: Value(item.asset.playbackStyle), source: TrashOrigin.localSync, ); @@ -147,6 +148,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { durationInSeconds: Value(asset.durationInSeconds), isFavorite: Value(asset.isFavorite), orientation: Value(asset.orientation), + playbackStyle: Value(asset.playbackStyle), createdAt: Value(asset.createdAt), updatedAt: Value(asset.updatedAt), source: const Value(TrashOrigin.remoteSync), @@ -195,6 +197,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { checksum: Value(e.checksum), isFavorite: Value(e.isFavorite), orientation: Value(e.orientation), + playbackStyle: Value(e.playbackStyle), ); }); @@ -245,6 +248,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { checksum: Value(e.asset.checksum), isFavorite: Value(e.asset.isFavorite), orientation: Value(e.asset.orientation), + playbackStyle: Value(e.asset.playbackStyle), source: TrashOrigin.localUser, albumId: e.albumId, ); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1316e66273..7e7c709eeb 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; @@ -39,7 +40,6 @@ import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/wm_executor.dart'; @@ -49,30 +49,33 @@ import 'package:logging/logging.dart'; import 'package:timezone/data/latest.dart'; void main() async { - ImmichWidgetsBinding(); - unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock()); - final (isar, drift, logDb) = await Bootstrap.initDB(); - await Bootstrap.initDomain(isar, drift, logDb); - await initApp(); - // Warm-up isolate pool for worker manager - await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); - await migrateDatabaseIfNeeded(isar, drift); - HttpSSLOptions.apply(); + try { + ImmichWidgetsBinding(); + unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock()); + await EasyLocalization.ensureInitialized(); + final (isar, drift, logDb) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDb); + await initApp(); + // Warm-up isolate pool for worker manager + await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); + await migrateDatabaseIfNeeded(isar, drift); - runApp( - ProviderScope( - overrides: [ - dbProvider.overrideWithValue(isar), - isarProvider.overrideWithValue(isar), - driftProvider.overrideWith(driftOverride(drift)), - ], - child: const MainWidget(), - ), - ); + runApp( + ProviderScope( + overrides: [ + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), + driftProvider.overrideWith(driftOverride(drift)), + ], + child: const MainWidget(), + ), + ); + } catch (error, stack) { + runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString())); + } } Future initApp() async { - await EasyLocalization.ensureInitialized(); await initializeDateFormatting(); if (Platform.isAndroid) { @@ -241,7 +244,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve @override void reassemble() { if (kDebugMode) { - NetworkRepository.reset(); + NetworkRepository.init(); } super.reassemble(); } diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index 635d925c3f..51a17de4fc 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -1,6 +1,5 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -21,7 +20,6 @@ class BackUpState { final DateTime progressInFileSpeedUpdateTime; final int progressInFileSpeedUpdateSentBytes; final double iCloudDownloadProgress; - final CancellationToken cancelToken; final ServerDiskInfo serverInfo; final bool autoBackup; final bool backgroundBackup; @@ -53,7 +51,6 @@ class BackUpState { required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateSentBytes, required this.iCloudDownloadProgress, - required this.cancelToken, required this.serverInfo, required this.autoBackup, required this.backgroundBackup, @@ -78,7 +75,6 @@ class BackUpState { DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, double? iCloudDownloadProgress, - CancellationToken? cancelToken, ServerDiskInfo? serverInfo, bool? autoBackup, bool? backgroundBackup, @@ -102,7 +98,6 @@ class BackUpState { progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, - cancelToken: cancelToken ?? this.cancelToken, serverInfo: serverInfo ?? this.serverInfo, autoBackup: autoBackup ?? this.autoBackup, backgroundBackup: backgroundBackup ?? this.backgroundBackup, @@ -120,7 +115,7 @@ class BackUpState { @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override @@ -137,7 +132,6 @@ class BackUpState { other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && other.iCloudDownloadProgress == iCloudDownloadProgress && - other.cancelToken == cancelToken && other.serverInfo == serverInfo && other.autoBackup == autoBackup && other.backgroundBackup == backgroundBackup && @@ -163,7 +157,6 @@ class BackUpState { progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^ iCloudDownloadProgress.hashCode ^ - cancelToken.hashCode ^ serverInfo.hashCode ^ autoBackup.hashCode ^ backgroundBackup.hashCode ^ diff --git a/mobile/lib/models/backup/manual_upload_state.model.dart b/mobile/lib/models/backup/manual_upload_state.model.dart index 7f797334de..120327c611 100644 --- a/mobile/lib/models/backup/manual_upload_state.model.dart +++ b/mobile/lib/models/backup/manual_upload_state.model.dart @@ -1,11 +1,8 @@ -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; class ManualUploadState { - final CancellationToken cancelToken; - // Current Backup Asset final CurrentUploadAsset currentUploadAsset; final int currentAssetIndex; @@ -29,7 +26,6 @@ class ManualUploadState { required this.progressInFileSpeeds, required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateSentBytes, - required this.cancelToken, required this.currentUploadAsset, required this.totalAssetsToUpload, required this.currentAssetIndex, @@ -44,7 +40,6 @@ class ManualUploadState { List? progressInFileSpeeds, DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, - CancellationToken? cancelToken, CurrentUploadAsset? currentUploadAsset, int? totalAssetsToUpload, int? successfulUploads, @@ -58,7 +53,6 @@ class ManualUploadState { progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds, progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, - cancelToken: cancelToken ?? this.cancelToken, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex, @@ -69,7 +63,7 @@ class ManualUploadState { @override String toString() { - return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; + return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; } @override @@ -84,7 +78,6 @@ class ManualUploadState { collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) && other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && - other.cancelToken == cancelToken && other.currentUploadAsset == currentUploadAsset && other.totalAssetsToUpload == totalAssetsToUpload && other.currentAssetIndex == currentAssetIndex && @@ -100,7 +93,6 @@ class ManualUploadState { progressInFileSpeeds.hashCode ^ progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^ - cancelToken.hashCode ^ currentUploadAsset.hashCode ^ totalAssetsToUpload.hashCode ^ currentAssetIndex.hashCode ^ diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index cd6c2a62b0..3ba3389eea 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -96,10 +96,6 @@ class _DriftBackupPageState extends ConsumerState { await backupNotifier.startForegroundBackup(currentUser.id); } - Future stopBackup() async { - await backupNotifier.stopForegroundBackup(); - } - return Scaffold( appBar: AppBar( elevation: 0, @@ -136,9 +132,9 @@ class _DriftBackupPageState extends ConsumerState { const Divider(), BackupToggleButton( onStart: () async => await startBackup(), - onStop: () async { + onStop: () { syncSuccess = null; - await stopBackup(); + backupNotifier.stopForegroundBackup(); }, ), switch (error) { @@ -152,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, + ), ), ], ), @@ -348,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) @@ -487,6 +486,7 @@ class _PreparingStatusState extends ConsumerState { style: context.textTheme.titleMedium?.copyWith( color: context.colorScheme.primary, fontWeight: FontWeight.w600, + fontFeatures: [const FontFeature.tabularFigures()], ), ), ], @@ -511,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/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 93ab659032..1732385675 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -112,16 +112,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { + backupNotifier.stopForegroundBackup(); unawaited( - backupNotifier.stopForegroundBackup().whenComplete( - () => backgroundSync.syncRemote().then((success) { - if (success) { - return backupNotifier.startForegroundBackup(user.id); - } else { - Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); - } - }), - ), + backgroundSync.syncRemote().then((success) { + if (success) { + return backupNotifier.startForegroundBackup(user.id); + } else { + Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); + } + }), ); } } diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index f43c8b6a8e..79891d7002 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -59,16 +59,15 @@ class DriftBackupOptionsPage extends ConsumerWidget { final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); + backupNotifier.stopForegroundBackup(); unawaited( - backupNotifier.stopForegroundBackup().whenComplete( - () => backgroundSync.syncRemote().then((success) { - if (success) { - return backupNotifier.startForegroundBackup(currentUser.id); - } else { - Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); - } - }), - ), + backgroundSync.syncRemote().then((success) { + if (success) { + return backupNotifier.startForegroundBackup(currentUser.id); + } else { + Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); + } + }), ); } }, diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 0ef27f854b..1d43bff167 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget { stackIndex.value = 0; ref.read(currentAssetProvider.notifier).set(newAsset); - if (newAsset.isVideo || newAsset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } // Wait for page change animation to finish, then precache the next image Timer(const Duration(milliseconds: 400), () { diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index c7c34b9cd2..6eba49442f 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; class SettingsHeader { String key = ""; @@ -20,7 +21,6 @@ class HeaderSettingsPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // final apiService = ref.watch(apiServiceProvider); final headers = useState>([]); final setInitialHeaders = useState(false); @@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ], ), body: PopScope( - onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), + onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value), child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), itemCount: list.length, @@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ); } - saveHeaders(List headers) { + saveHeaders(WidgetRef ref, List headers) async { final headersMap = {}; for (var header in headers) { final key = header.key.trim(); @@ -98,7 +98,8 @@ class HeaderSettingsPage extends HookConsumerWidget { } var encoded = jsonEncode(headersMap); - Store.put(StoreKey.customHeaders, encoded); + await Store.put(StoreKey.customHeaders, encoded); + await ref.read(apiServiceProvider).updateHeaders(); } } diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 9cd9f6bd5e..b1eed29c5c 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -11,18 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; -import 'package:immich_mobile/utils/debounce.dart'; -import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class NativeVideoViewerPage extends HookConsumerWidget { @@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final videoId = asset.id.toString(); final controller = useState(null); - final lastVideoPosition = useRef(-1); - final isBuffering = useRef(false); - - // Used to track whether the video should play when the app - // is brought back to the foreground final shouldPlayOnForeground = useRef(true); - // When a video is opened through the timeline, `isCurrent` will immediately be true. - // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. - // If the swipe is completed, `isCurrent` will be true for video B after a delay. - // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. final currentAsset = useState(ref.read(currentAssetProvider)); final isCurrent = currentAsset.value == asset; @@ -117,127 +105,45 @@ class NativeVideoViewerPage extends HookConsumerWidget { } }); - void checkIfBuffering() { - if (!context.mounted) { - return; - } - - final videoPlayback = ref.read(videoPlaybackValueProvider); - if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && - videoPlayback.state != VideoPlaybackState.buffering) { - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith( - state: VideoPlaybackState.buffering, - ); - } - } - - // Timer to mark videos as buffering if the position does not change - useInterval(const Duration(seconds: 5), checkIfBuffering); - - // When the position changes, seek to the position - // Debounce the seek to avoid seeking too often - // But also don't delay the seek too much to maintain visual feedback - final seekDebouncer = useDebouncer( - interval: const Duration(milliseconds: 100), - maxWaitTime: const Duration(milliseconds: 200), - ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - final oldSeek = oldControls?.position.inMilliseconds; - final newSeek = newControls.position.inMilliseconds; - if (oldSeek != newSeek || newControls.restarted) { - seekDebouncer.run(() => playerController.seekTo(newSeek)); - } - - if (oldControls?.pause != newControls.pause || newControls.restarted) { - unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); - } - }); - void onPlaybackReady() async { final videoController = controller.value; if (videoController == null || !isCurrent || !context.mounted) { return; } - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + notifier.onNativePlaybackReady(); isVideoReady.value = true; try { final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.autoPlayVideo); if (autoPlayVideo) { - await videoController.play(); + await notifier.play(); } - await videoController.setVolume(0.9); + await notifier.setVolume(1); } catch (error) { log.severe('Error playing video: $error'); } } void onPlaybackStatusChanged() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - if (videoPlayback.state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - - ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state; + if (!context.mounted) return; + ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged(); } void onPlaybackPositionChanged() { - // When seeking, these events sometimes move the slider to an older position - if (seekDebouncer.isActive) { - return; - } - - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final playbackInfo = videoController.playbackInfo; - if (playbackInfo == null) { - return; - } - - ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); - - // Check if the video is buffering - if (playbackInfo.status == PlaybackStatus.playing) { - isBuffering.value = lastVideoPosition.value == playbackInfo.position; - lastVideoPosition.value = playbackInfo.position; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; - } + if (!context.mounted) return; + ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged(); } void onPlaybackEnded() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } + if (!context.mounted) return; - if (videoController.playbackInfo?.status == PlaybackStatus.stopped && + ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded(); + + final videoController = controller.value; + if (videoController?.playbackInfo?.status == PlaybackStatus.stopped && !ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo)) { ref.read(isPlayingMotionVideoProvider.notifier).playing = false; } @@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (controller.value != null || !context.mounted) { return; } - ref.read(videoPlayerControlsProvider.notifier).reset(); - ref.read(videoPlaybackValueProvider.notifier).reset(); final source = await videoSource; if (source == null) { return; } + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + notifier.attachController(nc); + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); @@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { }), ); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - unawaited(nc.setLoop(loopVideo)); + await notifier.setLoop(loopVideo); controller.value = nc; - Timer(const Duration(milliseconds: 200), checkIfBuffering); } ref.listen(currentAssetProvider, (_, value) { @@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { } // Delay the video playback to avoid a stutter in the swipe animation - // Note, in some circumstances a longer delay is needed (eg: memories), - // the playbackDelayFactor can be used for this - // This delay seems like a hacky way to resolve underlying bugs in video - // playback, but other resolutions failed thus far Timer( Platform.isIOS ? Duration(milliseconds: 300 * playbackDelayFactor) @@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget { playerController.stop().catchError((error) { log.fine('Error stopping video: $error'); }); - - WakelockPlus.disable(); }; }, const []); useOnAppLifecycleStateChange((_, state) async { + final notifier = ref.read(videoPlayerProvider(videoId).notifier); if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - await controller.value?.play(); + await notifier.play(); } else if (state == AppLifecycleState.paused) { final videoPlaying = await controller.value?.isPlaying(); if (videoPlaying ?? true) { shouldPlayOnForeground.value = true; - await controller.value?.pause(); + await notifier.pause(); } else { shouldPlayOnForeground.value = false; } @@ -374,39 +275,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { ), ), ), - if (showControls) const Center(child: CustomVideoPlayerControls()), + if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)), ], ); } - - Future _onPauseChange( - BuildContext context, - NativeVideoPlayerController controller, - Debouncer seekDebouncer, - bool isPaused, - ) async { - if (!context.mounted) { - return; - } - - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - if (!context.mounted) { - return; - } - - try { - if (isPaused) { - await controller.pause(); - } else { - await controller.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } - } } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 0d728422d1..37c6b95806 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -1,10 +1,19 @@ import 'dart:async'; +import 'dart:io'; import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/generated/codegen_loader.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -13,7 +22,259 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/theme/color_scheme.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/widgets/common/immich_logo.dart'; +import 'package:immich_mobile/widgets/common/immich_title_text.dart'; import 'package:logging/logging.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl, LaunchMode; + +class BootstrapErrorWidget extends StatelessWidget { + final String error; + final String stack; + + const BootstrapErrorWidget({super.key, required this.error, required this.stack}); + + @override + Widget build(BuildContext _) { + final immichTheme = defaultColorPreset.themeOfPreset; + + return EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + useFallbackTranslations: true, + fallbackLocale: locales.values.first, + assetLoader: const CodegenLoader(), + child: Builder( + builder: (lCtx) => MaterialApp( + title: 'Immich', + debugShowCheckedModeBanner: true, + localizationsDelegates: lCtx.localizationDelegates, + supportedLocales: lCtx.supportedLocales, + locale: lCtx.locale, + themeMode: ThemeMode.system, + darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: lCtx.locale), + theme: getThemeData(colorScheme: immichTheme.light, locale: lCtx.locale), + home: Builder( + builder: (ctx) => Scaffold( + body: Column( + children: [ + const SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ImmichLogo(size: 48), SizedBox(width: 12), ImmichTitleText(fontSize: 24)], + ), + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: _ErrorCard(error: error, stack: stack), + ), + ), + const Divider(height: 1), + const SafeArea( + top: false, + child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BottomPanel extends StatefulWidget { + const _BottomPanel(); + + @override + State<_BottomPanel> createState() => _BottomPanelState(); +} + +class _BottomPanelState extends State<_BottomPanel> { + bool _cleared = false; + + Future _clearDatabase() async { + final confirmed = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(context.t.reset_sqlite_clear_app_data), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.t.reset_sqlite_confirmation), + const SizedBox(height: 12), + Text( + context.t.reset_sqlite_confirmation_note, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(false), child: Text(context.t.cancel)), + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(true), + child: Text(context.t.confirm, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + try { + final dir = await getApplicationDocumentsDirectory(); + for (final suffix in ['', '-wal', '-shm']) { + final file = File(path.join(dir.path, 'immich.sqlite$suffix')); + if (await file.exists()) { + await file.delete(); + } + } + } catch (_) { + return; + } + + if (mounted) { + setState(() => _cleared = true); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Text( + _cleared ? context.t.reset_sqlite_done : context.t.scaffold_body_error_unrecoverable, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionLink( + icon: Icons.chat_bubble_outline, + label: context.t.discord, + onTap: () => launchUrl(Uri.parse('https://discord.immich.app/'), mode: LaunchMode.externalApplication), + ), + _ActionLink( + icon: Icons.bug_report_outlined, + label: context.t.profile_drawer_github, + onTap: () => launchUrl( + Uri.parse('https://github.com/immich-app/immich/issues'), + mode: LaunchMode.externalApplication, + ), + ), + if (!_cleared) + _ActionLink( + icon: Icons.delete_outline, + label: context.t.reset_sqlite_clear_app_data, + onTap: _clearDatabase, + ), + ], + ), + ], + ); + } +} + +class _ActionLink extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _ActionLink({required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 24), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + ], + ), + ), + ); + } +} + +class _ErrorCard extends StatelessWidget { + final String error; + final String stack; + + const _ErrorCard({required this.error, required this.stack}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ColoredBox( + color: scheme.error, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + context.t.scaffold_body_error_occurred, + style: textTheme.titleSmall?.copyWith(color: scheme.onError), + ), + ), + IconButton( + tooltip: context.t.copy_error, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: Icon(Icons.copy_outlined, size: 16, color: scheme.onError), + onPressed: () => Clipboard.setData(ClipboardData(text: '$error\n\n$stack')), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: Text(error, style: textTheme.bodyMedium), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.t.stacktrace, style: textTheme.labelMedium), + const SizedBox(height: 4), + SelectableText(stack, style: textTheme.bodySmall?.copyWith(fontFamily: 'GoogleSansCode')), + ], + ), + ), + ], + ), + ); + } +} @RoutePage() class SplashScreenPage extends StatefulHookConsumerWidget { diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 8cd13fed64..a6a66c1358 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -7,11 +7,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; -import 'edit.page.dart'; - /// A widget for cropping an image. /// This widget uses [HookWidget] to manage its lifecycle and state. It allows /// users to crop an image and then navigate to the [EditImagePage] with the diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 20bd32a171..bd7973bc21 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart'; @@ -166,9 +165,6 @@ class MemoryPage extends HookConsumerWidget { final asset = currentMemory.value.assets[otherIndex]; currentAsset.value = asset; ref.read(currentAssetProvider.notifier).set(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called diff --git a/mobile/lib/platform/local_image_api.g.dart b/mobile/lib/platform/local_image_api.g.dart index 8b7c82f15d..f23cb86ced 100644 --- a/mobile/lib/platform/local_image_api.g.dart +++ b/mobile/lib/platform/local_image_api.g.dart @@ -55,6 +55,7 @@ class LocalImageApi { required int width, required int height, required bool isVideo, + required bool preferEncoded, }) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; @@ -69,6 +70,7 @@ class LocalImageApi { width, height, isVideo, + preferEncoded, ]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 61bed52411..6681912c2f 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -29,6 +29,8 @@ bool _deepEquals(Object? a, Object? b) { return a == b; } +enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } + class PlatformAsset { PlatformAsset({ required this.id, @@ -44,6 +46,7 @@ class PlatformAsset { this.adjustmentTime, this.latitude, this.longitude, + required this.playbackStyle, }); String id; @@ -72,6 +75,8 @@ class PlatformAsset { double? longitude; + PlatformAssetPlaybackStyle playbackStyle; + List _toList() { return [ id, @@ -87,6 +92,7 @@ class PlatformAsset { adjustmentTime, latitude, longitude, + playbackStyle, ]; } @@ -110,6 +116,7 @@ class PlatformAsset { adjustmentTime: result[10] as int?, latitude: result[11] as double?, longitude: result[12] as double?, + playbackStyle: result[13]! as PlatformAssetPlaybackStyle, ); } @@ -316,21 +323,24 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is PlatformAsset) { + } else if (value is PlatformAssetPlaybackStyle) { buffer.putUint8(129); - writeValue(buffer, value.encode()); - } else if (value is PlatformAlbum) { + writeValue(buffer, value.index); + } else if (value is PlatformAsset) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is SyncDelta) { + } else if (value is PlatformAlbum) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is HashResult) { + } else if (value is SyncDelta) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is CloudIdResult) { + } else if (value is HashResult) { buffer.putUint8(133); writeValue(buffer, value.encode()); + } else if (value is CloudIdResult) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -340,14 +350,17 @@ class _PigeonCodec extends StandardMessageCodec { Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { case 129: - return PlatformAsset.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null ? null : PlatformAssetPlaybackStyle.values[value]; case 130: - return PlatformAlbum.decode(readValue(buffer)!); + return PlatformAsset.decode(readValue(buffer)!); case 131: - return SyncDelta.decode(readValue(buffer)!); + return PlatformAlbum.decode(readValue(buffer)!); case 132: - return HashResult.decode(readValue(buffer)!); + return SyncDelta.decode(readValue(buffer)!); case 133: + return HashResult.decode(readValue(buffer)!); + case 134: return CloudIdResult.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 6ddb3cdb71..0ecbb430d3 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -179,7 +179,7 @@ class NetworkApi { } } - Future selectCertificate(ClientCertPrompt promptText) async { + Future selectCertificate(ClientCertPrompt promptText) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -197,13 +197,8 @@ class NetworkApi { message: pigeonVar_replyList[1] as String?, details: pigeonVar_replyList[2], ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); } else { - return (pigeonVar_replyList[0] as ClientCertData?)!; + return; } } @@ -229,4 +224,83 @@ class NetworkApi { return; } } + + Future hasCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + Future getClientPointer() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + 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( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + 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); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 410db03ece..474f033f1f 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -49,11 +49,7 @@ class RemoteImageApi { final String pigeonVar_messageChannelSuffix; - Future?> requestImage( - String url, { - required Map headers, - required int requestId, - }) async { + Future?> requestImage(String url, {required int requestId, required bool preferEncoded}) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -61,7 +57,7 @@ class RemoteImageApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, headers, requestId]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, requestId, preferEncoded]); 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_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 147165f2a3..3f8879c91d 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -7,16 +7,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; -/// Expects [currentAssetNotifier] to be set before navigating to this page +/// Expects the current asset to be set via [assetViewerProvider] before navigating to this page @RoutePage() class DriftMemoryPage extends HookConsumerWidget { final List memories; @@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget { static void setMemory(WidgetRef ref, DriftMemory memory) { if (memory.assets.isNotEmpty) { - ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first); - - if (memory.assets.first.isVideo) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } + ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first); } } @@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget { final asset = currentMemory.value.assets[otherIndex]; currentAsset.value = asset; - ref.read(currentAssetNotifier.notifier).setAsset(asset); - // if (asset.isVideo || asset.isMotionPhoto) { - if (asset.isVideo) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } + ref.read(assetViewerProvider.notifier).setAsset(asset); } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called @@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget { children: [ Container( color: Colors.black, - child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0), + child: DriftMemoryCard( + asset: asset, + title: title, + showTitle: index == 0, + isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value, + ), ), Positioned.fill( child: Row( diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index a10202973d..6d4ea4d3a6 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -62,7 +61,7 @@ class DriftEditImagePage extends ConsumerWidget { return; } - await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); + await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]); } catch (e) { ImmichToast.show( durationInSecond: 6, diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0ce3f20641..701a6ff74a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget { final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); - final isSearching = useState(false); - final userPreferences = ref.watch(userMetadataPreferencesProvider); - SnackBar searchInfoSnackBar(String message) { - return SnackBar( - content: Text(message, style: context.textTheme.labelLarge), - showCloseIcon: true, - behavior: SnackBarBehavior.fixed, - closeIconColor: context.colorScheme.onSurface, - ); - } - - searchFilter(SearchFilter filter) async { - if (filter.isEmpty) { - return; - } - + searchFilter(SearchFilter filter) { if (preFilter == null && filter == previousFilter.value) { return; } - isSearching.value = true; - ref.watch(paginatedSearchProvider.notifier).clear(); - final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter); + ref.read(paginatedSearchProvider.notifier).clear(); - if (!hasResult) { - context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context))); + if (filter.isEmpty) { + previousFilter.value = null; + return; } + unawaited(ref.read(paginatedSearchProvider.notifier).search(filter)); previousFilter.value = filter; - isSearching.value = false; } search() => searchFilter(filter.value); - loadMoreSearchResult() async { - isSearching.value = true; - final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); - - if (!hasResult) { - context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context))); - } - - isSearching.value = false; + loadMoreSearchResults() { + unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value)); } searchPreFilter() { @@ -742,10 +719,10 @@ class DriftSearchPage extends HookConsumerWidget { ), ), ), - if (isSearching.value) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())) + if (filter.value.isEmpty) + const _SearchSuggestions() else - _SearchResultGrid(onScrollEnd: loadMoreSearchResult), + _SearchResultGrid(onScrollEnd: loadMoreSearchResults), ], ), ); @@ -757,45 +734,85 @@ class _SearchResultGrid extends ConsumerWidget { const _SearchResultGrid({required this.onScrollEnd}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets)); + bool _onScrollUpdateNotification(ScrollNotification notification) { + final metrics = notification.metrics; - if (assets.isEmpty) { - return const _SearchEmptyContent(); + if (metrics.axis != Axis.vertical) return false; + + final isBottomSheet = notification.context?.findAncestorWidgetOfExactType() != null; + final remaining = metrics.maxScrollExtent - metrics.pixels; + + if (remaining < metrics.viewportDimension && !isBottomSheet) { + onScrollEnd(); } - return NotificationListener( - onNotification: (notification) { - final isBottomSheetNotification = - notification.context?.findAncestorWidgetOfExactType() != null; + return false; + } - final metrics = notification.metrics; - final isVerticalScroll = metrics.axis == Axis.vertical; + Widget? _bottomWidget(BuildContext context, WidgetRef ref) { + final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); - if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) { - onScrollEnd(); - ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent); - } + if (isLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ); + } - return true; - }, + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); + + if (hasMore) return null; + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Text( + 'search_no_more_result'.t(context: context), + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); + final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + + if (!hasAssets && !isLoading) { + return const _SearchNoResults(); + } + + return NotificationListener( + onNotification: _onScrollUpdateNotification, child: SliverFillRemaining( child: ProviderScope( overrides: [ timelineServiceProvider.overrideWith((ref) { - final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search); - ref.onDispose(timelineService.dispose); - return timelineService; + final notifier = ref.read(paginatedSearchProvider.notifier); + final service = ref + .watch(timelineFactoryProvider) + .fromAssetStream( + () => ref.read(paginatedSearchProvider).assets, + notifier.assetCount, + TimelineOrigin.search, + ); + ref.onDispose(service.dispose); + return service; }), ], child: Timeline( - key: ValueKey(assets.length), groupBy: GroupAssetsBy.none, appBar: null, bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, - initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)), + loadingWidget: const SizedBox.shrink(), + bottomSliverWidget: _bottomWidget(context, ref), ), ), ), @@ -803,8 +820,35 @@ class _SearchResultGrid extends ConsumerWidget { } } -class _SearchEmptyContent extends StatelessWidget { - const _SearchEmptyContent(); +class _SearchNoResults extends StatelessWidget { + const _SearchNoResults(); + + @override + Widget build(BuildContext context) { + return SliverFillRemaining( + hasScrollBody: false, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant), + const SizedBox(height: 24), + Text( + 'search_no_result'.t(context: context), + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ); + } +} + +class _SearchSuggestions extends StatelessWidget { + const _SearchSuggestions(); @override Widget build(BuildContext context) { diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart index e37aa7e0af..f65ca6b909 100644 --- a/mobile/lib/presentation/pages/search/paginated_search.provider.dart +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -1,5 +1,7 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; @@ -21,40 +23,52 @@ class SearchFilterProvider extends Notifier { } } -final paginatedSearchProvider = StateNotifierProvider( +class SearchState { + final List assets; + final int? nextPage; + final bool isLoading; + + const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false}); +} + +final paginatedSearchProvider = StateNotifierProvider( (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), ); -class PaginatedSearchNotifier extends StateNotifier { +class PaginatedSearchNotifier extends StateNotifier { final SearchService _searchService; + final _assetCountController = StreamController.broadcast(); - PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1)); + PaginatedSearchNotifier(this._searchService) : super(const SearchState()); - Future search(SearchFilter filter) async { - if (state.nextPage == null) { - return false; - } + Stream get assetCount => _assetCountController.stream; + + Future search(SearchFilter filter) async { + if (state.nextPage == null || state.isLoading) return; + + state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true); final result = await _searchService.search(filter, state.nextPage!); if (result == null) { - return false; + state = SearchState(assets: state.assets, nextPage: state.nextPage); + return; } - state = SearchResult( - assets: [...state.assets, ...result.assets], - nextPage: result.nextPage, - scrollOffset: state.scrollOffset, - ); + final assets = [...state.assets, ...result.assets]; + state = SearchState(assets: assets, nextPage: result.nextPage); - return true; + _assetCountController.add(assets.length); } - void setScrollOffset(double offset) { - state = state.copyWith(scrollOffset: offset); + void clear() { + state = const SearchState(); + _assetCountController.add(0); } - clear() { - state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0); + @override + void dispose() { + _assetCountController.close(); + super.dispose(); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 4162f43a24..39bdef8b9a 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState { } List _buildMenuChildren() { - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) return []; final user = ref.read(currentUserProvider); @@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState { } void _openAlbumSelector() { - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; if (currentAsset == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); return; @@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState { } Future _addCurrentAssetToAlbum(RemoteAlbum album) async { - final latest = ref.read(currentAssetNotifier); + final latest = ref.read(assetViewerProvider).currentAsset; if (latest == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); @@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState { @override Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 2dd9a265ed..710ec506c2 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; /// This delete action has the following behavior: @@ -25,6 +26,15 @@ class DeletePermanentActionButton extends ConsumerWidget { return; } + final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length; + final confirm = + await showDialog( + context: context, + builder: (context) => PermanentDeleteDialog(count: count), + ) ?? + false; + if (!confirm) return; + final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); ref.read(multiSelectProvider.notifier).reset(); diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart index 0d9bc41734..d19a188561 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart'; +import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; /// This delete action has the following behavior: @@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget { final confirmDelete = await showDialog( context: context, - builder: (context) => TrashDeleteDialog(count: selectCount), + builder: (context) => PermanentDeleteDialog(count: selectCount), ) ?? false; if (!confirmDelete) { diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 440985a0bb..cad74ce658 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { @@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentAsset = ref.watch(currentAssetNotifier); + final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); onPress() { if (currentAsset == null) { diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index a44b0b5815..96a7daa327 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(currentRemoteAlbumProvider); - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?; final user = ref.watch(currentUserProvider); final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id)); diff --git a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart index 294ddfd1f5..530c3fd8d4 100644 --- a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class SimilarPhotosActionButton extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index d69c5bced3..98eb09a4aa 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget { actions: [ ImmichTextButton( onPressed: () { - ref.read(manualUploadCancelTokenProvider)?.cancel(); + ref.read(manualUploadCancelTokenProvider)?.complete(); + ref.read(manualUploadCancelTokenProvider.notifier).state = null; Navigator.of(context).pop(); }, labelText: 'cancel'.t(context: context), diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 15749fb9af..0c039847a4 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget { return; } - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index 949a6917e9..dd5743a2d0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -1,5 +1,6 @@ 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/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; @@ -11,34 +12,36 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetDetails extends ConsumerWidget { + final BaseAsset asset; final double minHeight; - const AssetDetails({required this.minHeight, super.key}); + const AssetDetails({super.key, required this.asset, required this.minHeight}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } + final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull; + return Container( constraints: BoxConstraints(minHeight: minHeight), decoration: BoxDecoration( color: context.colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const DragHandle(), - const DateTimeDetails(), - const PeopleDetails(), - const LocationDetails(), - const TechnicalDetails(), - const RatingDetails(), - const AppearsInDetails(), - SizedBox(height: context.padding.bottom + 48), - ], + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DragHandle(), + DateTimeDetails(asset: asset, exifInfo: exifInfo), + PeopleDetails(asset: asset), + LocationDetails(asset: asset, exifInfo: exifInfo), + TechnicalDetails(asset: asset, exifInfo: exifInfo), + RatingDetails(exifInfo: exifInfo), + AppearsInDetails(asset: asset), + SizedBox(height: context.padding.bottom + 48), + ], + ), ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart index a3d6bdb8ab..fc15503a3f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -8,27 +8,25 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class AppearsInDetails extends ConsumerWidget { - const AppearsInDetails({super.key}); + final BaseAsset asset; + + const AppearsInDetails({super.key, required this.asset}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null || !asset.hasRemote) return const SizedBox.shrink(); + if (!asset.hasRemote) return const SizedBox.shrink(); - String? remoteAssetId; - if (asset is RemoteAsset) { - remoteAssetId = asset.id; - } else if (asset is LocalAsset) { - remoteAssetId = asset.remoteAssetId; - } + final remoteAssetId = switch (asset) { + RemoteAsset(:final id) => id, + LocalAsset(:final remoteAssetId) => remoteAssetId, + }; if (remoteAssetId == null) return const SizedBox.shrink(); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart index 4872bf9e75..27bac68310 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; const _kSeparator = ' â€ĸ '; class DateTimeDetails extends ConsumerWidget { - const DateTimeDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const DateTimeDetails({super.key, required this.asset, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) return const SizedBox.shrink(); - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final asset = this.asset; + final exifInfo = this.exifInfo; final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); return Column( @@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> @override Widget build(BuildContext context) { - final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - - final currentDescription = currentExifInfo?.description ?? ''; + final currentDescription = widget.exif.description ?? ''; final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( context: context, ); @@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, ), - onTapOutside: (_) => saveDescription(currentExifInfo?.description), + onTapOutside: (_) => saveDescription(widget.exif.description), ), ), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index 0665f4d46c..8c144a83bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class LocationDetails extends ConsumerStatefulWidget { - const LocationDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const LocationDetails({super.key, required this.asset, this.exifInfo}); @override ConsumerState createState() => _LocationDetailsState(); @@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState { _mapController = controller; } - void _onExifChanged(AsyncValue? previous, AsyncValue current) { - final currentExif = current.valueOrNull; - if (currentExif != null && currentExif.hasCoordinates) { - _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); - } - } - @override - void initState() { - super.initState(); - ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true); + void didUpdateWidget(LocationDetails oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.exifInfo != oldWidget.exifInfo) { + final exif = widget.exifInfo; + if (exif != null && exif.hasCoordinates) { + _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!))); + } + } } void editLocation() async { @@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState { @override Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final asset = widget.asset; + final exifInfo = widget.exifInfo; final hasCoordinates = exifInfo?.hasCoordinates ?? false; // Guard local assets diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 5074c63c9c..6c6f4a002c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; @@ -15,17 +14,14 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; -class PeopleDetails extends ConsumerStatefulWidget { - const PeopleDetails({super.key}); +class PeopleDetails extends ConsumerWidget { + final BaseAsset asset; + + const PeopleDetails({super.key, required this.asset}); @override - ConsumerState createState() => _PeopleDetailsState(); -} - -class _PeopleDetailsState extends ConsumerState { - @override - Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); + Widget build(BuildContext context, WidgetRef ref) { + final asset = this.asset; if (asset is! RemoteAsset) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart index 982ea67583..fb3a9dd8a8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; class RatingDetails extends ConsumerWidget { - const RatingDetails({super.key}); + final ExifInfo? exifInfo; + + const RatingDetails({super.key, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget { if (!isRatingEnabled) return const SizedBox.shrink(); - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - return Padding( padding: const EdgeInsets.only(left: 16.0, top: 16.0), child: Column( diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart index d79362b559..52d00828f1 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -6,21 +6,20 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; const _kSeparator = ' â€ĸ '; class TechnicalDetails extends ConsumerWidget { - const TechnicalDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const TechnicalDetails({super.key, required this.asset, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) return const SizedBox.shrink(); - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final exifInfo = this.exifInfo; final cameraTitle = _getCameraInfoTitle(exifInfo); final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; 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 61692e0538..012db3a130 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -12,17 +12,15 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_overlay.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; 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_controls_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -52,10 +50,7 @@ class _AssetPageState extends ConsumerState { bool _showingDetails = false; bool _isZoomed = false; - final _scrollController = ScrollController(); - late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); - final ValueNotifier _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial); - + final _scrollController = SnapScrollController(); double _snapOffset = 0.0; DragStartDetails? _dragStart; @@ -65,23 +60,21 @@ class _AssetPageState extends ConsumerState { @override void initState() { super.initState(); - _proxyScrollController.addListener(_onScroll); _eventSubscription = EventStream.shared.listen(_onEvent); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_proxyScrollController.hasClients) return; - _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (!mounted || !_scrollController.hasClients) return; + _scrollController.snapPosition.snapOffset = _snapOffset; if (_showingDetails && _snapOffset > 0) { - _proxyScrollController.jumpTo(_snapOffset); + _scrollController.jumpTo(_snapOffset); } }); } @override void dispose() { - _proxyScrollController.dispose(); + _scrollController.dispose(); _scaleBoundarySub?.cancel(); _eventSubscription?.cancel(); - _videoScaleStateNotifier.dispose(); super.dispose(); } @@ -94,20 +87,20 @@ class _AssetPageState extends ConsumerState { } void _showDetails() { - if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; - _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); + if (!_scrollController.hasClients || _snapOffset <= 0) return; + _viewer.setShowingDetails(true); + _scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); } - bool _willClose(double scrollVelocity) { - if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false; + bool _willClose(double scrollVelocity) => + _scrollController.hasClients && + _snapOffset > 0 && + _scrollController.position.pixels < _snapOffset && + SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) < + SnapScrollPhysics.minSnapDistance; - final position = _proxyScrollController.position; - return _proxyScrollController.position.pixels < _snapOffset && - SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance; - } - - void _onScroll() { - final offset = _proxyScrollController.offset; + void _syncShowingDetails() { + final offset = _scrollController.offset; if (offset > SnapScrollPhysics.minSnapDistance) { _viewer.setShowingDetails(true); } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { @@ -129,8 +122,8 @@ class _AssetPageState extends ConsumerState { } void _startProxyDrag() { - if (_proxyScrollController.hasClients && _dragStart != null) { - _drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null); + if (_scrollController.hasClients && _dragStart != null) { + _drag = _scrollController.position.drag(_dragStart!, () => _drag = null); } } @@ -150,6 +143,8 @@ class _AssetPageState extends ConsumerState { case _DragIntent.scroll: if (_drag == null) _startProxyDrag(); _drag?.update(details); + + _syncShowingDetails(); case _DragIntent.dismiss: _handleDragDown(context, details.localPosition - _dragStart!.localPosition); } @@ -168,9 +163,8 @@ class _AssetPageState extends ConsumerState { case _DragIntent.none: case _DragIntent.scroll: final scrollVelocity = -(details.primaryVelocity ?? 0.0); - if (_willClose(scrollVelocity)) { - _viewer.setShowingDetails(false); - } + _viewer.setShowingDetails(!_willClose(scrollVelocity)); + _drag?.end(details); _drag = null; case _DragIntent.dismiss: @@ -249,17 +243,11 @@ class _AssetPageState extends ConsumerState { ref.read(isPlayingMotionVideoProvider.notifier).playing = true; void _onScaleStateChanged(PhotoViewScaleState scaleState) { - _isZoomed = - scaleState == PhotoViewScaleState.zoomedIn || - scaleState == PhotoViewScaleState.covering || - _videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn || - _videoScaleStateNotifier.value == PhotoViewScaleState.covering; + _isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering; _viewer.setZoomed(_isZoomed); if (scaleState != PhotoViewScaleState.initial) { if (_dragStart == null) _viewer.setControls(false); - - ref.read(videoPlayerControlsProvider.notifier).pause(); return; } @@ -294,30 +282,26 @@ class _AssetPageState extends ConsumerState { _listenForScaleBoundaries(controller); } - Widget _buildPhotoView( - BaseAsset displayAsset, - BaseAsset asset, { - required bool isCurrentPage, - required bool showingDetails, + Widget _buildPhotoView({ + required BaseAsset asset, + required PhotoViewHeroAttributes? heroAttributes, + required bool isCurrent, required bool isPlayingMotionVideo, - required BoxDecoration backgroundDecoration, }) { - final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null; + final size = context.sizeData; - if (displayAsset.isImage && !isPlayingMotionVideo) { - final size = context.sizeData; + if (asset.isImage && !isPlayingMotionVideo) { return PhotoView( - key: ValueKey(displayAsset.heroTag), + key: Key(asset.heroTag), index: widget.index, - imageProvider: getFullImageProvider(displayAsset, size: size), + imageProvider: getFullImageProvider(asset, size: size), heroAttributes: heroAttributes, loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), - backgroundDecoration: backgroundDecoration, gaplessPlayback: true, filterQuality: FilterQuality.high, tightMode: true, enablePanAlways: true, - disableScaleGestures: showingDetails, + disableScaleGestures: _showingDetails, scaleStateChangedCallback: _onScaleStateChanged, onPageBuild: _onPageBuild, onDragStart: _onDragStart, @@ -325,41 +309,41 @@ class _AssetPageState extends ConsumerState { onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, onTapUp: _onTapUp, - onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, + onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, errorBuilder: (_, __, ___) => SizedBox( width: size.width, height: size.height, - child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), + child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), ), ); } return PhotoView.customChild( - key: ValueKey(displayAsset), + key: Key(asset.heroTag), + childSize: asset.width != null && asset.height != null + ? Size(asset.width!.toDouble(), asset.height!.toDouble()) + : null, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + scaleStateChangedCallback: _onScaleStateChanged, heroAttributes: heroAttributes, filterQuality: FilterQuality.high, basePosition: Alignment.center, - disableScaleGestures: true, + disableScaleGestures: _showingDetails, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, tightMode: true, onPageBuild: _onPageBuild, enablePanAlways: true, - backgroundDecoration: backgroundDecoration, child: NativeVideoViewer( - key: ValueKey(displayAsset), - asset: displayAsset, - scaleStateNotifier: _videoScaleStateNotifier, - disableScaleGestures: showingDetails, + key: _NativeVideoViewerKey(asset.heroTag), + asset: asset, + isCurrent: isCurrent, image: Image( - key: ValueKey(displayAsset.heroTag), - image: getFullImageProvider(displayAsset, size: context.sizeData), - height: context.height, - width: context.width, + image: getFullImageProvider(asset, size: size), fit: BoxFit.contain, alignment: Alignment.center, ), @@ -386,6 +370,8 @@ class _AssetPageState extends ConsumerState { displayAsset = stackChildren.elementAt(stackIndex); } + final isCurrent = currentHeroTag == displayAsset.heroTag; + final viewportWidth = MediaQuery.widthOf(context); final viewportHeight = MediaQuery.heightOf(context); final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); @@ -395,43 +381,29 @@ class _AssetPageState extends ConsumerState { _snapOffset = detailsOffset - snapTarget; - if (_proxyScrollController.hasClients) { - _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (_scrollController.hasClients) { + _scrollController.snapPosition.snapOffset = _snapOffset; } - return ProviderScope( - overrides: [ - currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)), - currentAssetExifProvider.overrideWith((ref) { - final a = ref.watch(currentAssetNotifier); - if (a == null) return Future.value(null); - return ref.watch(assetServiceProvider).getExif(a); - }), - ], - child: Stack( - children: [ - Offstage( - child: SingleChildScrollView( - controller: _proxyScrollController, - physics: const SnapScrollPhysics(), - child: const SizedBox.shrink(), - ), - ), - SingleChildScrollView( - controller: _scrollController, - physics: const NeverScrollableScrollPhysics(), + return Stack( + children: [ + SingleChildScrollView( + controller: _scrollController, + physics: const SnapScrollPhysics(), + child: ColoredBox( + color: _showingDetails ? Colors.black : Colors.transparent, child: Stack( children: [ SizedBox( width: viewportWidth, height: viewportHeight, child: _buildPhotoView( - displayAsset, - asset, - isCurrentPage: currentHeroTag == asset.heroTag, - showingDetails: _showingDetails, + asset: displayAsset, + heroAttributes: isCurrent + ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') + : null, + isCurrent: isCurrent, isPlayingMotionVideo: isPlayingMotionVideo, - backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), ), ), if (showingOcr && !_isZoomed && displayAsset.width != null && displayAsset.height != null) @@ -455,7 +427,7 @@ class _AssetPageState extends ConsumerState { child: AnimatedOpacity( opacity: _showingDetails ? 1.0 : 0.0, duration: Durations.short2, - child: AssetDetails(minHeight: viewportHeight - snapTarget), + child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget), ), ), ], @@ -464,8 +436,37 @@ class _AssetPageState extends ConsumerState { ], ), ), - ], - ), + ), + if (stackChildren != null && stackChildren.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: context.padding.bottom, + child: AssetStackRow(stack: stackChildren), + ), + ], ); } } + +// A global key is used for video viewers to prevent them from being +// unnecessarily recreated. They're quite expensive, and maintain internal +// state. This can cause videos to restart multiple times during normal usage, +// like a hero animation. +// +// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A +// GlobalObjectKey is fragile, as it checks if the given objects are identical, +// rather than equal. Hero tags are created with string interpolation, which +// prevents Dart from interning them. As such, hero tags are not identical, even +// if they are equal. +class _NativeVideoViewerKey extends GlobalKey { + final String value; + + const _NativeVideoViewerKey(this.value) : super.constructor(); + + @override + bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value; + + @override + int get hashCode => value.hashCode; +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 2835342b85..213dc92ef3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -1,53 +1,42 @@ 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/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetStackRow extends ConsumerWidget { - const AssetStackRow({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset)); - if (asset == null) { - return const SizedBox.shrink(); - } - - final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren == null || stackChildren.isEmpty) { - return const SizedBox.shrink(); - } - - final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); - if (showingDetails) { - return const SizedBox.shrink(); - } - return _StackList(stack: stackChildren); - } -} - -class _StackList extends ConsumerWidget { final List stack; - const _StackList({required this.stack}); + const AssetStackRow({super.key, required this.stack}); @override Widget build(BuildContext context, WidgetRef ref) { - return Center( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 5.0, - children: List.generate(stack.length, (i) { - final asset = stack[i]; - return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i); - }), + if (stack.isEmpty) { + return const SizedBox.shrink(); + } + + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); + + return IgnorePointer( + ignoring: opacity < 1.0, + child: AnimatedOpacity( + opacity: opacity, + duration: Durations.short2, + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 5.0, + children: List.generate(stack.length, (i) { + final asset = stack[i]; + return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i); + }), + ), + ), ), ), ), @@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget { class _StackItemState extends ConsumerState<_StackItem> { void _onTap() { - ref.read(currentAssetNotifier.notifier).setAsset(widget.asset); - ref.read(assetViewerProvider.notifier).setStackIndex(widget.index); + final notifier = ref.read(assetViewerProvider.notifier); + notifier.setAsset(widget.asset); + notifier.setStackIndex(widget.index); } @override diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 3ed5fb2034..903105406c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -17,13 +17,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget { } static void _setAsset(WidgetRef ref, BaseAsset asset) { - // Always holds the current asset from the timeline ref.read(assetViewerProvider.notifier).setAsset(asset); - // The currentAssetNotifier actually holds the current asset that is displayed - // which could be stack children as well - ref.read(currentAssetNotifier.notifier).setAsset(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - ref.read(videoPlayerControlsProvider.notifier).pause(); - } // Hide controls by default for videos if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); } @@ -91,6 +80,8 @@ class _AssetViewerState extends ConsumerState { late final _pageController = PageController(initialPage: widget.initialIndex); late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); + late int _currentPage = widget.initialIndex; + StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; @@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState { final target = page + direction; final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; if (target >= 0 && target <= maxPage) { + _currentPage = target; _pageController.jumpToPage(target); + _onAssetChanged(target); } } @@ -110,13 +103,16 @@ class _AssetViewerState extends ConsumerState { void initState() { super.initState(); - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; assert(asset != null, "Current asset should not be null when opening the AssetViewer"); if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); _reloadSubscription = EventStream.shared.listen(_onEvent); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); + + final assetViewer = ref.read(assetViewerProvider); + _setSystemUIMode(assetViewer.showingControls, assetViewer.showingDetails); } @override @@ -131,6 +127,26 @@ class _AssetViewerState extends ConsumerState { super.dispose(); } + // The normal onPageChange callback listens to OnScrollUpdate events, and will + // round the current page and update whenever that value changes. In practise, + // this means that the page will change when swiped half way, and may flip + // whilst dragging. + // + // Changing the page at the end of a scroll should be more robust, and allow + // the page to be dragged more than half way whilst keeping the current video + // playing, and preventing the video on the next page from becoming ready + // unnecessarily. + bool _onScrollEnd(ScrollEndNotification notification) { + if (notification.depth != 0) return false; + + final page = _pageController.page?.round(); + if (page != null && page != _currentPage) { + _currentPage = page; + _onAssetChanged(page); + } + return false; + } + void _onAssetInit(Duration timeStamp) { _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); @@ -150,7 +166,7 @@ class _AssetViewerState extends ConsumerState { void _handleCasting() { if (!ref.read(castProvider).isCasting) return; - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) return; if (asset is RemoteAsset) { @@ -192,17 +208,19 @@ class _AssetViewerState extends ConsumerState { } var index = _pageController.page?.round() ?? 0; - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; if (currentAsset != null) { final newIndex = timelineService.getIndex(currentAsset.heroTag); if (newIndex != null && newIndex != index) { index = newIndex; + _currentPage = index; _pageController.jumpToPage(index); } } if (index >= totalAssets) { index = totalAssets - 1; + _currentPage = index; _pageController.jumpToPage(index); } @@ -218,7 +236,7 @@ class _AssetViewerState extends ConsumerState { final newAsset = await timelineService.getAssetAsync(index); if (newAsset == null) return; - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; // Do not reload if the asset has not changed if (newAsset.heroTag == currentAsset?.heroTag) return; @@ -226,6 +244,13 @@ class _AssetViewerState extends ConsumerState { _onAssetChanged(index); } + void _setSystemUIMode(bool controls, bool details) { + final mode = !controls || (CurrentPlatform.isIOS && details) + ? SystemUiMode.immersiveSticky + : SystemUiMode.edgeToEdge; + unawaited(SystemChrome.setEnabledSystemUIMode(mode)); + } + @override Widget build(BuildContext context) { final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); @@ -245,31 +270,29 @@ class _AssetViewerState extends ConsumerState { ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) { final (controls, details) = state; - final mode = !controls || (CurrentPlatform.isIOS && details) - ? SystemUiMode.immersiveSticky - : SystemUiMode.edgeToEdge; - unawaited(SystemChrome.setEnabledSystemUIMode(mode)); + _setSystemUIMode(controls, details); }); - return PopScope( - onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(), - child: Scaffold( - backgroundColor: backgroundColor, - appBar: const ViewerTopAppBar(), - extendBody: true, - extendBodyBehindAppBar: true, - floatingActionButton: IgnorePointer( - ignoring: !showingControls, - child: AnimatedOpacity( - opacity: showingControls ? 1.0 : 0.0, - duration: Durations.short2, - child: const DownloadStatusFloatingButton(), - ), + return Scaffold( + backgroundColor: backgroundColor, + resizeToAvoidBottomInset: false, + appBar: const ViewerTopAppBar(), + extendBody: true, + extendBodyBehindAppBar: true, + floatingActionButton: IgnorePointer( + ignoring: !showingControls, + child: AnimatedOpacity( + opacity: showingControls ? 1.0 : 0.0, + duration: Durations.short2, + child: const DownloadStatusFloatingButton(), ), - bottomNavigationBar: const ViewerBottomAppBar(), - body: Stack( - children: [ - PhotoViewGestureDetectorScope( + ), + bottomNavigationBar: const ViewerBottomAppBar(), + body: Stack( + children: [ + NotificationListener( + onNotification: _onScrollEnd, + child: PhotoViewGestureDetectorScope( axis: Axis.horizontal, child: PageView.builder( controller: _pageController, @@ -279,21 +302,20 @@ class _AssetViewerState extends ConsumerState { ? const FastScrollPhysics() : const FastClampingScrollPhysics(), itemCount: ref.read(timelineServiceProvider).totalAssets, - onPageChanged: (index) => _onAssetChanged(index), itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), ), ), - if (!CurrentPlatform.isIOS) - IgnorePointer( - child: AnimatedContainer( - duration: Durations.short2, - color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), - height: context.padding.top, - ), + ), + if (!CurrentPlatform.isIOS) + IgnorePointer( + child: AnimatedContainer( + duration: Durations.short2, + color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), + height: context.padding.top, ), - ], - ), + ), + ], ), ); } 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 93006ab978..cc171f4490 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -9,8 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } @@ -62,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( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (asset.isVideo) const VideoControls(), - 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 0f6568e8fd..9285c01c41 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -1,471 +1,235 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/models/store.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/debounce.dart'; -import 'package:immich_mobile/utils/hooks/interval_hook.dart'; -import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; -bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) { - if (asset is RemoteAsset) { - return switch (currentAsset) { - RemoteAsset remoteAsset => remoteAsset.id == asset.id, - LocalAsset localAsset => localAsset.remoteId == asset.id, - _ => false, - }; - } else if (asset is LocalAsset) { - return switch (currentAsset) { - RemoteAsset remoteAsset => remoteAsset.localId == asset.id, - LocalAsset localAsset => localAsset.id == asset.id, - _ => false, - }; - } - return false; -} - -class NativeVideoViewer extends HookConsumerWidget { - static final log = Logger('NativeVideoViewer'); +class NativeVideoViewer extends ConsumerStatefulWidget { final BaseAsset asset; + final bool isCurrent; final bool showControls; - final int playbackDelayFactor; final Widget image; - final ValueNotifier? scaleStateNotifier; - final bool disableScaleGestures; const NativeVideoViewer({ super.key, required this.asset, required this.image, + this.isCurrent = false, this.showControls = true, - this.playbackDelayFactor = 1, - this.scaleStateNotifier, - this.disableScaleGestures = false, }); @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useState(null); - final lastVideoPosition = useRef(-1); - final isBuffering = useRef(false); + ConsumerState createState() => _NativeVideoViewerState(); +} - // Used to track whether the video should play when the app - // is brought back to the foreground - final shouldPlayOnForeground = useRef(true); +class _NativeVideoViewerState extends ConsumerState with WidgetsBindingObserver { + static final _log = Logger('NativeVideoViewer'); - // When a video is opened through the timeline, `isCurrent` will immediately be true. - // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. - // If the swipe is completed, `isCurrent` will be true for video B after a delay. - // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. - final currentAsset = useState(ref.read(currentAssetNotifier)); - final isCurrent = _isCurrentAsset(asset, currentAsset.value); + NativeVideoPlayerController? _controller; + late final Future _videoSource; + Timer? _loadTimer; + bool _isVideoReady = false; + bool _shouldPlayOnForeground = true; - // Used to show the placeholder during hero animations for remote videos to avoid a stutter - final isVisible = useState(Platform.isIOS && asset.hasLocal); + VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _videoSource = _createSource(); + } + + @override + void didUpdateWidget(NativeVideoViewer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return; + + if (!widget.isCurrent) { + _loadTimer?.cancel(); + _notifier.pause(); + return; + } + + // Prevent unnecessary loading when swiping between assets. + _loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _loadTimer?.cancel(); + _removeListeners(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + switch (state) { + case AppLifecycleState.resumed: + if (_shouldPlayOnForeground) await _notifier.play(); + case AppLifecycleState.paused: + _shouldPlayOnForeground = await _controller?.isPlaying() ?? true; + if (_shouldPlayOnForeground) await _notifier.pause(); + default: + } + } + + Future _createSource() async { + if (!mounted) return null; + + final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset; + if (!mounted) return null; + + try { + if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { + final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; + final file = await StorageRepository().getFileForAsset(id); + if (!mounted) return null; + + if (file == null) { + throw Exception('No file found for the video'); + } + + // Pass a file:// URI so Android's Uri.parse doesn't + // interpret characters like '#' as fragment identifiers. + return VideoSource.init( + path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, + type: VideoSourceType.file, + ); + } + + final remoteId = (videoAsset as RemoteAsset).id; + + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); + final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; + final String videoUrl = videoAsset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' + : '$serverEndpoint/assets/$remoteId/$postfixUrl'; + + return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders()); + } catch (error) { + _log.severe('Error creating video source for asset ${videoAsset.name}: $error'); + return null; + } + } + + void _onPlaybackReady() async { + if (!mounted || !widget.isCurrent) return; + + _notifier.onNativePlaybackReady(); + + // onPlaybackReady may be called multiple times, usually when more data + // loads. If this is not the first time that the player has become ready, we + // should not autoplay. + if (_isVideoReady) return; + + setState(() => _isVideoReady = true); + + if (ref.read(assetViewerProvider).showingDetails) return; + + final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); + if (autoPlayVideo) await _notifier.play(); + } + + void _onPlaybackEnded() { + if (!mounted) return; + + _notifier.onNativePlaybackEnded(); + + if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + } + } + + void _onPlaybackPositionChanged() { + if (!mounted) return; + _notifier.onNativePositionChanged(); + } + + void _onPlaybackStatusChanged() { + if (!mounted) return; + _notifier.onNativeStatusChanged(); + } + + void _removeListeners() { + _controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged); + _controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged); + _controller?.onPlaybackReady.removeListener(_onPlaybackReady); + _controller?.onPlaybackEnded.removeListener(_onPlaybackEnded); + } + + void _loadVideo() async { + final nc = _controller; + if (nc == null || nc.videoSource != null || !mounted) return; + + final source = await _videoSource; + if (source == null || !mounted) return; + + await _notifier.load(source); + final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); + await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); + await _notifier.setVolume(1); + } + + void _initController(NativeVideoPlayerController nc) { + if (_controller != null || !mounted) return; + + _notifier.attachController(nc); + + nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged); + nc.onPlaybackReady.addListener(_onPlaybackReady); + nc.onPlaybackEnded.addListener(_onPlaybackEnded); + + _controller = nc; + + if (widget.isCurrent) _loadVideo(); + } + + @override + Widget build(BuildContext context) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status)); - Future createSource() async { - if (!context.mounted) { - return null; - } - - final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset; - if (!context.mounted) { - return null; - } - - try { - if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { - final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; - final file = await StorageRepository().getFileForAsset(id); - if (!context.mounted) { - return null; - } - - if (file == null) { - throw Exception('No file found for the video'); - } - - // Pass a file:// URI so Android's Uri.parse doesn't - // interpret characters like '#' as fragment identifiers. - final source = await VideoSource.init( - path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, - type: VideoSourceType.file, - ); - return source; - } - - final remoteId = (videoAsset as RemoteAsset).id; - - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); - final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; - final String videoUrl = videoAsset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' - : '$serverEndpoint/assets/$remoteId/$postfixUrl'; - - final source = await VideoSource.init( - path: videoUrl, - type: VideoSourceType.network, - headers: ApiService.getRequestHeaders(), - ); - return source; - } catch (error) { - log.severe('Error creating video source for asset ${videoAsset.name}: $error'); - return null; - } - } - - final videoSource = useMemoized>(() => createSource()); - final aspectRatio = useState(null); - - useMemoized(() async { - if (!context.mounted || aspectRatio.value != null) { - return null; - } - - try { - aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset); - } catch (error) { - log.severe('Error getting aspect ratio for asset ${asset.name}: $error'); - } - }, [asset.heroTag]); - - void checkIfBuffering() { - if (!context.mounted) { - return; - } - - final videoPlayback = ref.read(videoPlaybackValueProvider); - if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && - videoPlayback.state != VideoPlaybackState.buffering) { - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith( - state: VideoPlaybackState.buffering, - ); - } - } - - // Timer to mark videos as buffering if the position does not change - useInterval(const Duration(seconds: 5), checkIfBuffering); - - // When the position changes, seek to the position - // Debounce the seek to avoid seeking too often - // But also don't delay the seek too much to maintain visual feedback - final seekDebouncer = useDebouncer( - interval: const Duration(milliseconds: 100), - maxWaitTime: const Duration(milliseconds: 200), - ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - final oldSeek = oldControls?.position.inMilliseconds; - final newSeek = newControls.position.inMilliseconds; - if (oldSeek != newSeek || newControls.restarted) { - seekDebouncer.run(() => playerController.seekTo(newSeek)); - } - - if (oldControls?.pause != newControls.pause || newControls.restarted) { - unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); - } - }); - - void onPlaybackReady() async { - final videoController = controller.value; - if (videoController == null || !isCurrent || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - - if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) { - return; - } - - try { - final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); - if (autoPlayVideo) { - await videoController.play(); - } - await videoController.setVolume(0.9); - } catch (error) { - log.severe('Error playing video: $error'); - } - } - - void onPlaybackStatusChanged() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - if (videoPlayback.state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - - ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state; - } - - void onPlaybackPositionChanged() { - // When seeking, these events sometimes move the slider to an older position - if (seekDebouncer.isActive) { - return; - } - - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final playbackInfo = videoController.playbackInfo; - if (playbackInfo == null) { - return; - } - - ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); - - // Check if the video is buffering - if (playbackInfo.status == PlaybackStatus.playing) { - isBuffering.value = lastVideoPosition.value == playbackInfo.position; - lastVideoPosition.value = playbackInfo.position; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; - } - } - - void onPlaybackEnded() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - if (videoController.playbackInfo?.status == PlaybackStatus.stopped) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = false; - } - } - - void removeListeners(NativeVideoPlayerController controller) { - controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged); - controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged); - controller.onPlaybackReady.removeListener(onPlaybackReady); - controller.onPlaybackEnded.removeListener(onPlaybackEnded); - } - - void initController(NativeVideoPlayerController nc) async { - if (controller.value != null || !context.mounted) { - return; - } - ref.read(videoPlayerControlsProvider.notifier).reset(); - ref.read(videoPlaybackValueProvider.notifier).reset(); - - final source = await videoSource; - if (source == null || !context.mounted) { - return; - } - - nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); - nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); - nc.onPlaybackReady.addListener(onPlaybackReady); - nc.onPlaybackEnded.addListener(onPlaybackEnded); - - unawaited( - nc.loadVideoSource(source).catchError((error) { - log.severe('Error loading video source: $error'); - }), - ); - final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo)); - - controller.value = nc; - Timer(const Duration(milliseconds: 200), checkIfBuffering); - } - - Size? videoContextSize(double? videoAspectRatio, BuildContext? context) { - Size? videoContextSize; - if (videoAspectRatio == null || context == null) { - return null; - } - final contextAspectRatio = context.width / context.height; - if (videoAspectRatio > contextAspectRatio) { - videoContextSize = Size(context.width, context.width / aspectRatio.value!); - } else { - videoContextSize = Size(context.height * aspectRatio.value!, context.height); - } - return videoContextSize; - } - - ref.listen(currentAssetNotifier, (_, value) { - final playerController = controller.value; - if (playerController != null && value != asset) { - removeListeners(playerController); - } - - if (value != null) { - isVisible.value = _isCurrentAsset(value, asset); - } - final curAsset = currentAsset.value; - if (curAsset == asset) { - return; - } - - final imageToVideo = curAsset != null && !curAsset.isVideo; - - // No need to delay video playback when swiping from an image to a video - if (imageToVideo && Platform.isIOS) { - currentAsset.value = value; - onPlaybackReady(); - return; - } - - // Delay the video playback to avoid a stutter in the swipe animation - // Note, in some circumstances a longer delay is needed (eg: memories), - // the playbackDelayFactor can be used for this - // This delay seems like a hacky way to resolve underlying bugs in video - // playback, but other resolutions failed thus far - Timer( - Platform.isIOS - ? Duration(milliseconds: 300 * playbackDelayFactor) - : imageToVideo - ? Duration(milliseconds: 200 * playbackDelayFactor) - : Duration(milliseconds: 400 * playbackDelayFactor), - () { - if (!context.mounted) { - return; - } - - currentAsset.value = value; - if (currentAsset.value == asset) { - onPlaybackReady(); - } - }, - ); - }); - - useEffect(() { - // If opening a remote video from a hero animation, delay visibility to avoid a stutter - final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true); - - return () { - timer?.cancel(); - final playerController = controller.value; - if (playerController == null) { - return; - } - removeListeners(playerController); - playerController.stop().catchError((error) { - log.fine('Error stopping video: $error'); - }); - - WakelockPlus.disable(); - }; - }, const []); - - useOnAppLifecycleStateChange((_, state) async { - if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - await controller.value?.play(); - } else if (state == AppLifecycleState.paused) { - final videoPlaying = await controller.value?.isPlaying(); - if (videoPlaying ?? true) { - shouldPlayOnForeground.value = true; - await controller.value?.pause(); - } else { - shouldPlayOnForeground.value = false; - } - } - }); - - return SizedBox( - width: context.width, - height: context.height, + return IgnorePointer( child: Stack( children: [ - // Hide thumbnail once video is visible to avoid it showing in background when zooming out on video. - if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image), - if (aspectRatio.value != null && !isCasting && isCurrent) + Center(child: widget.image), + if (!isCasting) ...[ Visibility.maintain( - key: ValueKey(asset), - visible: isVisible.value, - child: PhotoView.customChild( - key: ValueKey(asset), - enableRotation: false, - disableScaleGestures: disableScaleGestures, - // Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet. - backgroundDecoration: const BoxDecoration(color: Colors.transparent), - scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state, - childSize: videoContextSize(aspectRatio.value, context), - child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController), + 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(), ), ), - if (showControls) const Center(child: VideoViewerControls()), + ], ], ), ); } - - Future _onPauseChange( - BuildContext context, - NativeVideoPlayerController controller, - Debouncer seekDebouncer, - bool isPaused, - ) async { - if (!context.mounted) { - return; - } - - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - try { - if (isPaused) { - await controller.pause(); - } else { - await controller.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } - } } 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 28cfe5e73c..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/models/cast/cast_manager_state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.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 Duration hideTimerDuration; - - const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); - bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); - if (showingDetails) { - showControls = false; - } - final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); - - final cast = ref.watch(castProvider); - - // A timer to hide the controls - final hideTimer = useTimer(hideTimerDuration, () { - if (!context.mounted) { - return; - } - final state = ref.read(videoPlaybackValueProvider).state; - - // Do not hide on paused - if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - }); - final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; - - /// Shows the controls and starts the timer to hide them - void showControlsAndStartHideTimer() { - hideTimer.reset(); - ref.read(assetViewerProvider.notifier).setControls(true); - } - - // When we change position, show or hide timer - ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { - showControlsAndStartHideTimer(); - }); - - /// Toggles between playing and pausing depending on the state of the video - void togglePlay() { - showControlsAndStartHideTimer(); - - if (cast.isCasting) { - if (cast.castState == CastState.playing) { - ref.read(castProvider.notifier).pause(); - } else if (cast.castState == CastState.paused) { - ref.read(castProvider.notifier).play(); - } else if (cast.castState == CastState.idle) { - // resend the play command since its finished - final asset = ref.read(currentAssetNotifier); - if (asset == null) { - return; - } - // ref.read(castProvider.notifier).loadMedia(asset, true); - } - return; - } - - if (state == VideoPlaybackState.playing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); - } else if (state == VideoPlaybackState.completed) { - ref.read(videoPlayerControlsProvider.notifier).restart(); - } else { - ref.read(videoPlayerControlsProvider.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: state == VideoPlaybackState.completed, - isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), - show: assetIsVideo && showControls, - onPressed: togglePlay, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart index aa3b8bb93f..1c0b600843 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; class ViewerBottomAppBar extends ConsumerWidget { @@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - - if (!showControls) { - opacity = 0.0; - } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); return IgnorePointer( ignoring: opacity < 1.0, - child: AnimatedOpacity( - opacity: opacity, - duration: Durations.short2, - child: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], - ), - ), + child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index fb25e9e1cb..78b2e50da5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -5,7 +5,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/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } 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 d5f887b244..5fdb761da0 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 @@ -9,10 +9,9 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -23,7 +22,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } @@ -37,16 +36,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final hasOcr = asset is RemoteAsset && ref.watch(driftOcrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true; final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { ref.watch(albumActivityProvider(album.id, asset.id)); } - if (!showControls) { - opacity = 0.0; - } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); final originalTheme = context.themeData; final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr)); @@ -88,17 +84,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, + ), ), ), ); @@ -114,17 +122,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/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 9481ec12f5..cdff393a3f 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const DownloadActionButton(source: ActionSource.timeline), + if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index fb6034b869..1dee0f6456 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const UnFavoriteActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), - const DownloadActionButton(source: ActionSource.timeline), + if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index fea3da88e5..8753a9c14f 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState { const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), - const DownloadActionButton(source: ActionSource.timeline), + if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), @@ -119,10 +119,11 @@ class _GeneralBottomSheetState extends ConsumerState { const MoveToLockFolderActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), - if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline), + if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline), ], - if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline), - if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline), + if (multiselect.onlyLocal || multiselect.hasMerged) + const DeleteLocalActionButton(source: ActionSource.timeline), + if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline), ], slivers: multiselect.hasRemote ? [ 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 c3cda46e81..bf29f9482f 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:async/async.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -75,6 +77,29 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } } + Future loadCodecRequest(ImageRequest request) async { + if (isCancelled) { + this.request = null; + PaintingBinding.instance.imageCache.evict(this); + return null; + } + + try { + final codec = await request.loadCodec(); + if (codec == null || isCancelled) { + codec?.dispose(); + PaintingBinding.instance.imageCache.evict(this); + return null; + } + return codec; + } catch (e) { + PaintingBinding.instance.imageCache.evict(this); + rethrow; + } finally { + this.request = null; + } + } + Stream initialImageStream() async* { final cachedOperation = this.cachedOperation; if (cachedOperation == null) { @@ -115,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; @@ -128,7 +153,12 @@ 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); + provider = RemoteFullImageProvider( + assetId: assetId, + thumbhash: thumbhash, + assetType: asset.type, + isAnimated: asset.isAnimatedImage, + ); } 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 e7e5deb6a6..65ef4e28eb 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -4,9 +4,9 @@ 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/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; @@ -37,7 +37,7 @@ class RemoteImageProvider extends CancellableImageProvider } Stream _codec(RemoteImageProvider key, ImageDecoderCallback decode) { - final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders()); + final request = this.request = RemoteImageRequest(uri: key.url); return loadRequest(request, decode); } @@ -59,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -69,12 +75,27 @@ 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(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('isAnimated', key.isAnimated), ], onLastListenerRemoved: cancel, ); @@ -88,10 +109,8 @@ 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; + return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated; } return false; } @override - int get hashCode => assetId.hashCode ^ thumbhash.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index d6485ae7b6..2ceaf80db0 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.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/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart index eaed60b204..3df9c8074e 100644 --- a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget { final RemoteAsset asset; final String title; final bool showTitle; + final bool isCurrent; final Function()? onVideoEnded; const DriftMemoryCard({ required this.asset, required this.title, required this.showTitle, + this.isCurrent = false, this.onVideoEnded, super.key, }); @@ -37,33 +39,35 @@ class DriftMemoryCard extends StatelessWidget { SizedBox.expand(child: _BlurredBackdrop(asset: asset)), LayoutBuilder( builder: (context, constraints) { + final r = asset.width != null && asset.height != null + ? asset.width! / asset.height! + : constraints.maxWidth / constraints.maxHeight; + // Determine the fit using the aspect ratio BoxFit fit = BoxFit.contain; if (asset.width != null && asset.height != null) { - final aspectRatio = asset.width! / asset.height!; final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight; // Look for a 25% difference in either direction - if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) { + if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) { // Cover to look nice if we have nearly the same aspect ratio fit = BoxFit.cover; } } - if (asset.isImage) { - return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity)); - } else { - return SizedBox( - width: context.width, - height: context.height, + if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity)); + + return Center( + child: AspectRatio( + aspectRatio: r, child: NativeVideoViewer( key: ValueKey(asset.id), asset: asset, + isCurrent: isCurrent, showControls: false, - playbackDelayFactor: 2, - image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain), + image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain), ), - ); - } + ), + ); }, ), if (showTitle) diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index 3b4269925c..84892c79f7 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256); const double kTimelineSpacing = 2.0; const int kTimelineColumnCount = 3; +const double kScrubberThumbHeight = 48.0; const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index d31048fbb5..f0dfef571c 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget { elevation: 4.0, color: backgroundColor, borderRadius: const BorderRadius.only( - topLeft: Radius.circular(48.0), - bottomLeft: Radius.circular(48.0), + topLeft: Radius.circular(kScrubberThumbHeight), + bottomLeft: Radius.circular(kScrubberThumbHeight), topRight: Radius.circular(4.0), bottomRight: Radius.circular(4.0), ), - child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))), + child: Container( + constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)), + ), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 4d72a9b0a5..8d494a8452 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; @@ -34,6 +35,7 @@ class Timeline extends StatelessWidget { super.key, this.topSliverWidget, this.topSliverWidgetHeight, + this.bottomSliverWidget, this.showStorageIndicator = false, this.withStack = false, this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), @@ -41,13 +43,14 @@ class Timeline extends StatelessWidget { this.groupBy, this.withScrubber = true, this.snapToMonth = true, - this.initialScrollOffset, this.readOnly = false, this.persistentBottomBar = false, + this.loadingWidget, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; @@ -55,9 +58,9 @@ class Timeline extends StatelessWidget { final GroupAssetsBy? groupBy; final bool withScrubber; final bool snapToMonth; - final double? initialScrollOffset; final bool readOnly; final bool persistentBottomBar; + final Widget? loadingWidget; @override Widget build(BuildContext context) { @@ -82,13 +85,14 @@ class Timeline extends StatelessWidget { child: _SliverTimeline( topSliverWidget: topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight, + bottomSliverWidget: bottomSliverWidget, appBar: appBar, bottomSheet: bottomSheet, withScrubber: withScrubber, persistentBottomBar: persistentBottomBar, snapToMonth: snapToMonth, - initialScrollOffset: initialScrollOffset, maxWidth: constraints.maxWidth, + loadingWidget: loadingWidget, ), ), ), @@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, this.topSliverWidgetHeight, + this.bottomSliverWidget, this.appBar, this.bottomSheet, this.withScrubber = true, this.persistentBottomBar = false, this.snapToMonth = true, - this.initialScrollOffset, this.maxWidth, + this.loadingWidget, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; final bool persistentBottomBar; final bool snapToMonth; - final double? initialScrollOffset; final double? maxWidth; + final Widget? loadingWidget; @override ConsumerState createState() => _SliverTimelineState(); @@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void initState() { super.initState(); - _scrollController = ScrollController( - initialScrollOffset: widget.initialScrollOffset ?? 0.0, - onAttach: _restoreAssetPosition, - ); + _scrollController = ScrollController(onAttach: _restoreAssetPosition); _eventSubscription = EventStream.shared.listen(_onEvent); final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); @@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } }, child: asyncSegments.widgetWhen( + onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null, onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar @@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { : 0; final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; - const scrubberBottomPadding = 100.0; const bottomSheetOpenModifier = 120.0; - final bottomPadding = - context.padding.bottom + - (widget.appBar == null ? 0 : scrubberBottomPadding) + - (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight; final grid = CustomScrollView( primary: true, @@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { addRepaintBoundaries: false, ), ), - SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), + if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!, + SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)), ], ); @@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { layoutSegments: segments, timelineHeight: maxHeight, topPadding: topPadding, - bottomPadding: bottomPadding, + bottomPadding: scrubberBottomPadding, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, hasAppBar: widget.appBar != null, child: grid, diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 883c4f4835..68007f283a 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -232,7 +232,7 @@ class AppLifeCycleNotifier extends StateNotifier { } } - Future _performPause() async { + Future _performPause() { if (_ref.read(authProvider).isAuthenticated) { if (!Store.isBetaTimelineEnabled) { // Do not cancel backup if manual upload is in progress @@ -240,15 +240,13 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(backupProvider.notifier).cancelBackup(); } } else { - await _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); + _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); } _ref.read(websocketProvider.notifier).disconnect(); } - try { - await LogService.I.flush(); - } catch (_) {} + return LogService.I.flush().catchError((_) {}); } Future handleAppDetached() async { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart similarity index 79% rename from mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart rename to mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index b94f5f0b51..cdbab29b6e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -1,5 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; class AssetViewerState { @@ -74,6 +76,12 @@ class AssetViewerState { class AssetViewerStateNotifier extends Notifier { @override AssetViewerState build() { + ref.listen(_watchedCurrentAssetProvider, (_, next) { + final updated = next.valueOrNull; + if (updated != null) { + state = state.copyWith(currentAsset: updated); + } + }); return const AssetViewerState(); } @@ -81,10 +89,8 @@ class AssetViewerStateNotifier extends Notifier { state = const AssetViewerState(); } - void setAsset(BaseAsset? asset) { - if (asset == state.currentAsset) { - return; - } + void setAsset(BaseAsset asset) { + if (asset == state.currentAsset) return; state = state.copyWith(currentAsset: asset, stackIndex: 0); } @@ -100,8 +106,11 @@ class AssetViewerStateNotifier extends Notifier { return; } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); - if (showing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); + + final heroTag = state.currentAsset?.heroTag; + if (heroTag != null) { + final notifier = ref.read(videoPlayerProvider(heroTag).notifier); + showing ? notifier.hold() : notifier.release(); } } @@ -136,3 +145,10 @@ class AssetViewerStateNotifier extends Notifier { } final assetViewerProvider = NotifierProvider(AssetViewerStateNotifier.new); + +final _watchedCurrentAssetProvider = StreamProvider((ref) { + ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + final asset = ref.read(assetViewerProvider).currentAsset; + if (asset == null) return const Stream.empty(); + return ref.read(assetServiceProvider).watchAsset(asset); +}); diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart deleted file mode 100644 index 44740268db..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; - -class VideoPlaybackControls { - const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false}); - - final Duration position; - final bool pause; - final bool restarted; -} - -final videoPlayerControlsProvider = StateNotifierProvider((ref) { - return VideoPlayerControls(ref); -}); - -const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false); - -class VideoPlayerControls extends StateNotifier { - VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); - - final Ref ref; - - VideoPlaybackControls get value => state; - - set value(VideoPlaybackControls value) { - state = value; - } - - void reset() { - state = videoPlayerControlsDefault; - } - - Duration get position => state.position; - bool get paused => state.pause; - - set position(Duration value) { - if (state.position == value) { - return; - } - - state = VideoPlaybackControls(position: value, pause: state.pause); - } - - void pause() { - if (state.pause) { - return; - } - - state = VideoPlaybackControls(position: state.position, pause: true); - } - - void play() { - if (!state.pause) { - return; - } - - state = VideoPlaybackControls(position: state.position, pause: false); - } - - void togglePlay() { - state = VideoPlaybackControls(position: state.position, pause: !state.pause); - } - - void restart() { - state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true); - ref.read(videoPlaybackValueProvider.notifier).value = ref - .read(videoPlaybackValueProvider.notifier) - .value - .copyWith(state: VideoPlaybackState.playing, position: Duration.zero); - } -} diff --git a/mobile/lib/providers/asset_viewer/video_player_provider.dart b/mobile/lib/providers/asset_viewer/video_player_provider.dart new file mode 100644 index 0000000000..a4a8bd1762 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/video_player_provider.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +enum VideoPlaybackStatus { paused, playing, buffering, completed } + +class VideoPlayerState { + final Duration position; + final Duration duration; + final VideoPlaybackStatus status; + + const VideoPlayerState({required this.position, required this.duration, required this.status}); + + VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) { + return VideoPlayerState( + position: position ?? this.position, + duration: duration ?? this.duration, + status: status ?? this.status, + ); + } +} + +const _defaultState = VideoPlayerState( + position: Duration.zero, + duration: Duration.zero, + status: VideoPlaybackStatus.paused, +); + +final videoPlayerProvider = StateNotifierProvider.autoDispose.family(( + ref, + name, +) { + return VideoPlayerNotifier(); +}); + +class VideoPlayerNotifier extends StateNotifier { + static final _log = Logger('VideoPlayerNotifier'); + + VideoPlayerNotifier() : super(_defaultState); + + NativeVideoPlayerController? _controller; + Timer? _bufferingTimer; + Timer? _seekTimer; + VideoPlaybackStatus? _holdStatus; + + @override + void dispose() { + _bufferingTimer?.cancel(); + _seekTimer?.cancel(); + WakelockPlus.disable(); + _controller = null; + + 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; + + _bufferingTimer?.cancel(); + + try { + await _controller!.pause(); + await _flushSeek(); + } catch (e) { + _log.severe('Error pausing video: $e'); + } + } + + Future play() async { + if (_controller == null) return; + + try { + await _flushSeek(); + await _controller!.play(); + } catch (e) { + _log.severe('Error playing video: $e'); + } + + _startBufferingTimer(); + } + + Future _flushSeek() async { + final timer = _seekTimer; + if (timer == null || !timer.isActive) return; + + timer.cancel(); + await _controller?.seekTo(state.position.inMilliseconds); + } + + void seekTo(Duration position) { + if (_controller == null || state.position == position) return; + + state = state.copyWith(position: position); + + 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(); + } + + Future setVolume(double volume) async { + try { + await _controller?.setVolume(volume); + } catch (e) { + _log.severe('Error setting volume: $e'); + } + } + + Future setLoop(bool loop) async { + try { + await _controller?.setLoop(loop); + } catch (e) { + _log.severe('Error setting loop: $e'); + } + } + + void onNativePlaybackReady() { + if (!mounted) return; + + final playbackInfo = _controller?.playbackInfo; + final videoInfo = _controller?.videoInfo; + + if (playbackInfo == null || videoInfo == null) return; + + state = state.copyWith( + position: Duration(milliseconds: playbackInfo.position), + duration: Duration(milliseconds: videoInfo.duration), + status: _mapStatus(playbackInfo.status), + ); + } + + void onNativePositionChanged() { + if (!mounted || (_seekTimer?.isActive ?? false)) return; + + final playbackInfo = _controller?.playbackInfo; + if (playbackInfo == null) return; + + final position = Duration(milliseconds: playbackInfo.position); + if (state.position == position) return; + + if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer(); + + state = state.copyWith( + position: position, + status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null, + ); + } + + void onNativeStatusChanged() { + if (!mounted) return; + + final playbackInfo = _controller?.playbackInfo; + if (playbackInfo == null) return; + + final newStatus = _mapStatus(playbackInfo.status); + switch (newStatus) { + case VideoPlaybackStatus.playing: + WakelockPlus.enable(); + _startBufferingTimer(); + default: + onNativePlaybackEnded(); + } + + if (state.status != newStatus) state = state.copyWith(status: newStatus); + } + + void onNativePlaybackEnded() { + WakelockPlus.disable(); + _bufferingTimer?.cancel(); + } + + void _startBufferingTimer() { + _bufferingTimer?.cancel(); + _bufferingTimer = Timer(const Duration(seconds: 3), () { + if (mounted && state.status != VideoPlaybackStatus.completed) { + state = state.copyWith(status: VideoPlaybackStatus.buffering); + } + }); + } + + static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) { + PlaybackStatus.playing => VideoPlaybackStatus.playing, + PlaybackStatus.paused => VideoPlaybackStatus.paused, + PlaybackStatus.stopped => VideoPlaybackStatus.completed, + }; +} diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart deleted file mode 100644 index 31b0f4656e..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:native_video_player/native_video_player.dart'; - -enum VideoPlaybackState { initializing, paused, playing, buffering, completed } - -class VideoPlaybackValue { - /// The current position of the video - final Duration position; - - /// The total duration of the video - final Duration duration; - - /// The current state of the video playback - final VideoPlaybackState state; - - /// The volume of the video - final double volume; - - const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume}); - - factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) { - final playbackInfo = controller.playbackInfo; - final videoInfo = controller.videoInfo; - - if (playbackInfo == null || videoInfo == null) { - return videoPlaybackValueDefault; - } - - final VideoPlaybackState status = switch (playbackInfo.status) { - PlaybackStatus.playing => VideoPlaybackState.playing, - PlaybackStatus.paused => VideoPlaybackState.paused, - PlaybackStatus.stopped => VideoPlaybackState.completed, - }; - - return VideoPlaybackValue( - position: Duration(milliseconds: playbackInfo.position), - duration: Duration(milliseconds: videoInfo.duration), - state: status, - volume: playbackInfo.volume, - ); - } - - VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) { - return VideoPlaybackValue( - position: position ?? this.position, - duration: duration ?? this.duration, - state: state ?? this.state, - volume: volume ?? this.volume, - ); - } -} - -const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - state: VideoPlaybackState.initializing, - volume: 0.0, -); - -final videoPlaybackValueProvider = StateNotifierProvider((ref) { - return VideoPlaybackValueState(ref); -}); - -class VideoPlaybackValueState extends StateNotifier { - VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault); - - final Ref ref; - - VideoPlaybackValue get value => state; - - set value(VideoPlaybackValue value) { - state = value; - } - - set position(Duration value) { - if (state.position == value) return; - state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume); - } - - set status(VideoPlaybackState value) { - if (state.state == value) return; - state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume); - } - - void reset() { - state = videoPlaybackValueDefault; - } -} diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 49dc10240b..825d9e7bc8 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -123,7 +123,8 @@ 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); final customHeaders = Store.tryGet(StoreKey.customHeaders); @@ -144,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/backup/asset_upload_progress.provider.dart b/mobile/lib/providers/backup/asset_upload_progress.provider.dart index e8aba430da..60936ef871 100644 --- a/mobile/lib/providers/backup/asset_upload_progress.provider.dart +++ b/mobile/lib/providers/backup/asset_upload_progress.provider.dart @@ -1,4 +1,5 @@ -import 'package:cancellation_token_http/http.dart'; +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; /// Tracks per-asset upload progress. @@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider((ref) => null); +final manualUploadCancelTokenProvider = StateProvider?>((ref) => null); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 9eb01b6109..5f3ad3d058 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -68,7 +68,6 @@ class BackupNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), autoBackup: Store.get(StoreKey.autoBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true), @@ -102,6 +101,7 @@ class BackupNotifier extends StateNotifier { final FileMediaRepository _fileMediaRepository; final BackupAlbumService _backupAlbumService; final Ref ref; + Completer? _cancelToken; /// /// UI INTERACTION @@ -454,7 +454,8 @@ class BackupNotifier extends StateNotifier { } // Perform Backup - state = state.copyWith(cancelToken: CancellationToken()); + _cancelToken?.complete(); + _cancelToken = Completer(); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; @@ -465,7 +466,7 @@ class BackupNotifier extends StateNotifier { await _backupService.backupAsset( assetsWillBeBackup, - state.cancelToken, + _cancelToken!, pmProgressHandler: pmProgressHandler, onSuccess: _onAssetUploaded, onProgress: _onUploadProgress, @@ -494,7 +495,8 @@ class BackupNotifier extends StateNotifier { if (state.backupProgress != BackUpProgressEnum.inProgress) { notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + _cancelToken?.complete(); + _cancelToken = null; state = state.copyWith( backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0, diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 624c21f158..4507747c7d 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; @@ -109,7 +108,6 @@ class DriftBackupState { final BackupError error; final Map uploadItems; - final CancellationToken? cancelToken; final Map iCloudDownloadProgress; @@ -121,7 +119,6 @@ class DriftBackupState { required this.isSyncing, this.error = BackupError.none, required this.uploadItems, - this.cancelToken, this.iCloudDownloadProgress = const {}, }); @@ -133,7 +130,6 @@ class DriftBackupState { bool? isSyncing, BackupError? error, Map? uploadItems, - CancellationToken? cancelToken, Map? iCloudDownloadProgress, }) { return DriftBackupState( @@ -144,7 +140,6 @@ class DriftBackupState { isSyncing: isSyncing ?? this.isSyncing, error: error ?? this.error, uploadItems: uploadItems ?? this.uploadItems, - cancelToken: cancelToken ?? this.cancelToken, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, ); } @@ -153,7 +148,7 @@ class DriftBackupState { @override String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, iCloudDownloadProgress: $iCloudDownloadProgress)'; } @override @@ -168,8 +163,7 @@ class DriftBackupState { other.isSyncing == isSyncing && other.error == error && mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && - mapEquals(other.uploadItems, uploadItems) && - other.cancelToken == cancelToken; + mapEquals(other.uploadItems, uploadItems); } @override @@ -181,7 +175,6 @@ class DriftBackupState { isSyncing.hashCode ^ error.hashCode ^ uploadItems.hashCode ^ - cancelToken.hashCode ^ iCloudDownloadProgress.hashCode; } } @@ -211,6 +204,7 @@ class DriftBackupNotifier extends StateNotifier { final ForegroundUploadService _foregroundUploadService; final BackgroundUploadService _backgroundUploadService; final UploadSpeedManager _uploadSpeedManager; + Completer? _cancelToken; final _logger = Logger("DriftBackupNotifier"); @@ -246,7 +240,7 @@ class DriftBackupNotifier extends StateNotifier { ); } - void updateError(BackupError error) async { + void updateError(BackupError error) { if (!mounted) { _logger.warning("Skip updateError: notifier disposed"); return; @@ -254,24 +248,23 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(error: error); } - void updateSyncing(bool isSyncing) async { + void updateSyncing(bool isSyncing) { state = state.copyWith(isSyncing: isSyncing); } - Future startForegroundBackup(String userId) async { + Future startForegroundBackup(String userId) { // Cancel any existing backup before starting a new one - if (state.cancelToken != null) { - await stopForegroundBackup(); + if (_cancelToken != null) { + stopForegroundBackup(); } state = state.copyWith(error: BackupError.none); - final cancelToken = CancellationToken(); - state = state.copyWith(cancelToken: cancelToken); + _cancelToken = Completer(); return _foregroundUploadService.uploadCandidates( userId, - cancelToken, + _cancelToken!, callbacks: UploadCallbacks( onProgress: _handleForegroundBackupProgress, onSuccess: _handleForegroundBackupSuccess, @@ -281,10 +274,11 @@ class DriftBackupNotifier extends StateNotifier { ); } - Future stopForegroundBackup() async { - state.cancelToken?.cancel(); + void stopForegroundBackup() { + _cancelToken?.complete(); + _cancelToken = null; _uploadSpeedManager.clear(); - state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); + state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {}); } void _handleICloudProgress(String localAssetId, double progress) { @@ -300,7 +294,7 @@ class DriftBackupNotifier extends StateNotifier { } void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { - if (state.cancelToken == null) { + if (_cancelToken == null) { return; } @@ -399,7 +393,7 @@ class DriftBackupNotifier extends StateNotifier { } } -final driftBackupCandidateProvider = FutureProvider.autoDispose>((ref) async { +final driftBackupCandidateProvider = FutureProvider.autoDispose>((ref) { final user = ref.watch(currentUserProvider); if (user == null) { return []; diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 6ad8730356..40efcd7422 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; @@ -50,6 +49,7 @@ class ManualUploadNotifier extends StateNotifier { final BackupService _backupService; final BackupAlbumService _backupAlbumService; final Ref ref; + Completer? _cancelToken; ManualUploadNotifier( this._localNotificationService, @@ -65,7 +65,6 @@ class ManualUploadNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), currentUploadAsset: CurrentUploadAsset( id: '...', fileCreatedAt: DateTime.parse('2020-10-04'), @@ -236,7 +235,6 @@ class ManualUploadNotifier extends StateNotifier { fileName: '...', fileType: '...', ), - cancelToken: CancellationToken(), ); // Reset Error List ref.watch(errorBackupListProvider.notifier).empty(); @@ -252,11 +250,13 @@ class ManualUploadNotifier extends StateNotifier { state = state.copyWith(showDetailedNotification: showDetailedNotification); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; + _cancelToken?.complete(); + _cancelToken = Completer(); final bool ok = await ref .read(backupServiceProvider) .backupAsset( uploadAssets, - state.cancelToken, + _cancelToken!, pmProgressHandler: pmProgressHandler, onSuccess: _onAssetUploaded, onProgress: _onProgress, @@ -273,14 +273,14 @@ class ManualUploadNotifier extends StateNotifier { ); // User cancelled upload - if (!ok && state.cancelToken.isCancelled) { + if (!ok && _cancelToken == null) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "backup_manual_cancelled".tr(), presentBanner: true, ); hasErrors = true; - } else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) { + } else if (state.successfulUploads == 0 || (!ok && _cancelToken != null)) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "failed".tr(), @@ -324,7 +324,8 @@ class ManualUploadNotifier extends StateNotifier { _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + _cancelToken?.complete(); + _cancelToken = null; if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); } 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/providers/image/cache/image_loader.dart b/mobile/lib/providers/image/cache/image_loader.dart deleted file mode 100644 index 50530f7cdf..0000000000 --- a/mobile/lib/providers/image/cache/image_loader.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; -import 'package:immich_mobile/services/api.service.dart'; - -/// Loads the codec from the URI and sends the events to the [chunkEvents] stream -/// -/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) -/// for this wonderful implementation of their image loader -class ImageLoader { - static Future loadImageFromCache( - String uri, { - required CacheManager cache, - required ImageDecoderCallback decode, - StreamController? chunkEvents, - }) async { - final headers = ApiService.getRequestHeaders(); - - final stream = cache.getFileStream(uri, withProgress: chunkEvents != null, headers: headers); - - await for (final result in stream) { - if (result is DownloadProgress) { - // We are downloading the file, so update the [chunkEvents] - chunkEvents?.add( - ImageChunkEvent(cumulativeBytesLoaded: result.downloaded, expectedTotalBytes: result.totalSize), - ); - } else if (result is FileInfo) { - // We have the file - final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); - return decode(buffer); - } - } - - // If we get here, the image failed to load from the cache stream - throw const ImageLoadingException('Could not load image from stream'); - } -} diff --git a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart b/mobile/lib/providers/image/cache/remote_image_cache_manager.dart deleted file mode 100644 index d3de4b80c9..0000000000 --- a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; - -class RemoteImageCacheManager extends CacheManager { - static const key = 'remoteImageCacheKey'; - static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); - static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)); - - factory RemoteImageCacheManager() { - return _instance; - } - - RemoteImageCacheManager._() : super(_config); -} - -class RemoteThumbnailCacheManager extends CacheManager { - static const key = 'remoteThumbnailCacheKey'; - static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._(); - static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)); - - factory RemoteThumbnailCacheManager() { - return _instance; - } - - RemoteThumbnailCacheManager._() : super(_config); -} diff --git a/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart b/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart deleted file mode 100644 index bfea36eef6..0000000000 --- a/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; - -/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider] -class ThumbnailImageCacheManager extends CacheManager { - static const key = 'thumbnailImageCacheKey'; - static final ThumbnailImageCacheManager _instance = ThumbnailImageCacheManager._(); - - factory ThumbnailImageCacheManager() { - return _instance; - } - - ThumbnailImageCacheManager._() : super(Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30))); -} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index c06bcabf26..bad0d986d0 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -2,15 +2,14 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -123,7 +122,7 @@ class ActionNotifier extends Notifier { Set _getAssets(ActionSource source) { return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, - ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { + ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) { BaseAsset asset => {asset}, null => const {}, }, @@ -307,7 +306,10 @@ class ActionNotifier extends Notifier { // does not update the currentAsset which means // the exif provider will not be refreshed automatically if (source == ActionSource.viewer) { - ref.invalidate(currentAssetExifProvider); + final currentAsset = ref.read(assetViewerProvider).currentAsset; + if (currentAsset != null) { + ref.invalidate(assetExifProvider(currentAsset)); + } } return ActionResult(count: ids.length, success: true); @@ -409,7 +411,6 @@ class ActionNotifier extends Notifier { if (source == ActionSource.viewer) { final updatedParent = await _assetService.getRemoteAsset(assets.first.id); if (updatedParent != null) { - ref.read(currentAssetNotifier.notifier).setAsset(updatedParent); ref.read(assetViewerProvider.notifier).setAsset(updatedParent); } } @@ -453,7 +454,7 @@ class ActionNotifier extends Notifier { final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); final progressNotifier = ref.read(assetUploadProgressProvider.notifier); - final cancelToken = CancellationToken(); + final cancelToken = Completer(); ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; // Initialize progress for all assets @@ -464,7 +465,7 @@ class ActionNotifier extends Notifier { try { await _foregroundUploadService.uploadManual( assetsToUpload, - cancelToken, + cancelToken: cancelToken, callbacks: UploadCallbacks( onProgress: (localAssetId, filename, bytes, totalBytes) { final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; diff --git a/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart index 5718333759..82ab69b994 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart @@ -1,52 +1,8 @@ -import 'dart:async'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -final currentAssetNotifier = AutoDisposeNotifierProvider(CurrentAssetNotifier.new); - -class CurrentAssetNotifier extends AutoDisposeNotifier { - KeepAliveLink? _keepAliveLink; - StreamSubscription? _assetSubscription; - - @override - BaseAsset? build() => null; - - void setAsset(BaseAsset asset) { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - state = asset; - _assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) { - if (updatedAsset != null) { - state = updatedAsset; - } - }); - _keepAliveLink = ref.keepAlive(); - } - - void dispose() { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - } -} - -class ScopedAssetNotifier extends CurrentAssetNotifier { - final BaseAsset _asset; - - ScopedAssetNotifier(this._asset); - - @override - BaseAsset? build() { - setAsset(_asset); - return _asset; - } -} - -final currentAssetExifProvider = FutureProvider.autoDispose((ref) { - final currentAsset = ref.watch(currentAssetNotifier); - if (currentAsset == null) { - return null; - } - return ref.watch(assetServiceProvider).getExif(currentAsset); +final assetExifProvider = FutureProvider.autoDispose.family((ref, asset) { + return ref.watch(assetServiceProvider).getExif(asset); }); diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index 0965f4349b..6fc75b8e6a 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -1,11 +1,10 @@ import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'db.provider.dart'; - final driftMemoryRepositoryProvider = Provider( (ref) => DriftMemoryRepository(ref.watch(driftProvider)), ); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index e3cffeb093..606ce3f129 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -6,11 +6,10 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'album.provider.dart'; - class RemoteAlbumState { final List albums; diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 0b3f7e610b..6e375f3852 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -24,10 +24,12 @@ class MultiSelectState { bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null); - bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local); - bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged); + bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local); + + bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote); + MultiSelectState copyWith({ Set? selectedAssets, Set? lockedSelectionAssets, diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index f9473ce440..6643404786 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,18 +1,17 @@ import 'dart:async'; -import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -99,11 +98,6 @@ class WebsocketNotifier extends StateNotifier { if (authenticationState.isAuthenticated) { try { final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint)); - final headers = ApiService.getRequestHeaders(); - if (endpoint.userInfo.isNotEmpty) { - headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}"; - } - dPrint(() => "Attempting to connect to websocket"); // Configure socket transports must be specified Socket socket = io( @@ -111,11 +105,11 @@ class WebsocketNotifier extends StateNotifier { OptionBuilder() .setPath("${endpoint.path}/socket.io") .setTransports(['websocket']) + .setWebSocketConnector(NetworkRepository.createWebSocket) .enableReconnection() .enableForceNew() .enableForceNewConnection() .enableAutoConnect() - .setExtraHeaders(headers) .build(), ); @@ -160,11 +154,8 @@ class WebsocketNotifier extends StateNotifier { _batchedAssetUploadReady.clear(); - var socket = state.socket?.disconnect(); - - if (socket?.disconnected == true) { - state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); - } + state.socket?.dispose(); + state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); } void stopListenToEvent(String eventName) { @@ -319,7 +310,7 @@ class WebsocketNotifier extends StateNotifier { } void _handleSyncAssetEditReady(dynamic data) { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data])); + unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data)); } void _processBatchedAssetUploadReady() { 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/repositories/file_media.repository.dart b/mobile/lib/repositories/file_media.repository.dart index 3a3e50f370..f5cdb6d5c0 100644 --- a/mobile/lib/repositories/file_media.repository.dart +++ b/mobile/lib/repositories/file_media.repository.dart @@ -25,6 +25,7 @@ class FileMediaRepository { type: AssetType.image, createdAt: entity.createDateTime, updatedAt: entity.modifiedDateTime, + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); } diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index aff84683c3..98c6202e19 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -3,21 +3,15 @@ import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:logging/logging.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -class UploadTaskWithFile { - final File file; - final UploadTask task; - - const UploadTaskWithFile({required this.file, required this.task}); -} - final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { @@ -97,26 +91,27 @@ class UploadRepository { Future uploadFile({ required File file, required String originalFileName, - required Map headers, required Map fields, - required Client httpClient, - required CancellationToken cancelToken, - required void Function(int bytes, int totalBytes) onProgress, + required Completer? cancelToken, + void Function(int bytes, int totalBytes)? onProgress, required String logContext, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final baseRequest = ProgressMultipartRequest( + 'POST', + Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken?.future, + onProgress: onProgress, + ); try { final fileStream = file.openRead(); final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); - final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress); - - baseRequest.headers.addAll(headers); baseRequest.fields.addAll(fields); baseRequest.files.add(assetRawUploadData); - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await NetworkRepository.client.send(baseRequest); final responseBodyString = await response.stream.bytesToString(); if (![200, 201].contains(response.statusCode)) { @@ -145,7 +140,7 @@ class UploadRepository { } catch (e) { return UploadResult.error(errorMessage: 'Failed to parse server response'); } - } on CancelledException { + } on RequestAbortedException { logger.warning("Upload $logContext was cancelled"); return UploadResult.cancelled(); } catch (error, stackTrace) { @@ -155,6 +150,34 @@ class UploadRepository { } } +class ProgressMultipartRequest extends MultipartRequest with Abortable { + ProgressMultipartRequest(super.method, super.url, {this.abortTrigger, this.onProgress}); + + @override + final Future? abortTrigger; + + final void Function(int bytes, int totalBytes)? onProgress; + + @override + ByteStream finalize() { + final byteStream = super.finalize(); + if (onProgress == null) return byteStream; + + final total = contentLength; + var bytes = 0; + final stream = byteStream.transform( + StreamTransformer.fromHandlers( + handleData: (List data, EventSink> sink) { + bytes += data.length; + onProgress!(bytes, total); + sink.add(data); + }, + ), + ); + return ByteStream(stream); + } +} + class UploadResult { final bool isSuccess; final bool isCancelled; @@ -182,26 +205,3 @@ class UploadResult { return const UploadResult(isSuccess: false, isCancelled: true); } } - -class _CustomMultipartRequest extends MultipartRequest { - _CustomMultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - @override - ByteStream finalize() { - final byteStream = super.finalize(); - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return ByteStream(stream); - } -} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index bafe780647..e296ac522d 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -3,16 +3,15 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/url_helper.dart'; -import 'package:immich_mobile/utils/user_agent.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -class ApiService implements Authentication { +class ApiService { late ApiClient _apiClient; late UsersApi usersApi; @@ -46,15 +45,16 @@ class ApiService implements Authentication { setEndpoint(endpoint); } } - String? _accessToken; final _log = Logger("ApiService"); + Future updateHeaders() async { + await NetworkRepository.setHeaders(getRequestHeaders(), getServerUrls()); + _apiClient.client = NetworkRepository.client; + } + setEndpoint(String endpoint) { - _apiClient = ApiClient(basePath: endpoint, authentication: this); - _setUserAgentHeader(); - if (_accessToken != null) { - setAccessToken(_accessToken!); - } + _apiClient = ApiClient(basePath: endpoint); + _apiClient.client = NetworkRepository.client; usersApi = UsersApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient); oAuthApi = AuthenticationApi(_apiClient); @@ -78,11 +78,6 @@ class ApiService implements Authentication { tagsApi = TagsApi(_apiClient); } - Future _setUserAgentHeader() async { - final userAgent = await getUserAgentString(); - _apiClient.addDefaultHeader('User-Agent', userAgent); - } - Future resolveAndSetEndpoint(String serverUrl) async { final endpoint = await resolveEndpoint(serverUrl); setEndpoint(endpoint); @@ -136,14 +131,9 @@ class ApiService implements Authentication { } Future _getWellKnownEndpoint(String baseUrl) async { - final Client client = Client(); - try { - var headers = {"Accept": "application/json"}; - headers.addAll(getRequestHeaders()); - - final res = await client - .get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers) + final res = await NetworkRepository.client + .get(Uri.parse("$baseUrl/.well-known/immich")) .timeout(const Duration(seconds: 5)); if (res.statusCode == 200) { @@ -163,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,32 +170,34 @@ 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; + static List getServerUrls() { + final urls = []; + final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint); + if (serverEndpoint != null && serverEndpoint.isNotEmpty) { + urls.add(serverEndpoint); } - - if (customHeadersStr.isEmpty) { - return header; + final localEndpoint = Store.tryGet(StoreKey.localEndpoint); + if (localEndpoint != null && localEndpoint.isNotEmpty) { + urls.add(localEndpoint); } - - var customHeaders = jsonDecode(customHeadersStr) as Map; - customHeaders.forEach((key, value) { - header[key] = value; - }); - - return header; + final externalJson = Store.tryGet(StoreKey.externalEndpointList); + if (externalJson != null) { + final List list = jsonDecode(externalJson); + for (final entry in list) { + final url = entry['url'] as String?; + if (url != null && url.isNotEmpty) urls.add(url); + } + } + return urls; } - @override - Future applyToParams(List queryParams, Map headerParams) { - return Future(() { - var headers = ApiService.getRequestHeaders(); - headerParams.addAll(headers); - }); + static Map getRequestHeaders() { + var customHeadersStr = Store.get(StoreKey.customHeaders, ""); + if (customHeadersStr.isEmpty) { + return const {}; + } + + return (jsonDecode(customHeadersStr) as Map).cast(); } ApiClient get apiClient => _apiClient; diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 3173f49957..c5f3fa6a4a 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -64,27 +64,16 @@ class AuthService { } Future validateAuxilaryServerUrl(String url) async { - final httpclient = HttpClient(); bool isValid = false; try { final uri = Uri.parse('$url/users/me'); - final request = await httpclient.getUrl(uri); - - // add auth token + any configured custom headers - final customHeaders = ApiService.getRequestHeaders(); - customHeaders.forEach((key, value) { - request.headers.add(key, value); - }); - - final response = await request.close(); + final response = await NetworkRepository.client.get(uri); if (response.statusCode == 200) { isValid = true; } } catch (error) { _log.severe("Error validating auxiliary endpoint", error); - } finally { - httpclient.close(); } return isValid; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index b69aa53014..03278d25fc 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; @@ -30,7 +29,6 @@ import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -43,7 +41,7 @@ class BackgroundService { static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel'); static const notifyInterval = Duration(milliseconds: 400); bool _isBackgroundInitialized = false; - CancellationToken? _cancellationToken; + Completer? _cancellationToken; bool _canceledBySystem = false; int _wantsLockTime = 0; bool _hasLock = false; @@ -321,7 +319,8 @@ class BackgroundService { } case "systemStop": _canceledBySystem = true; - _cancellationToken?.cancel(); + _cancellationToken?.complete(); + _cancellationToken = null; return true; default: dPrint(() => "Unknown method ${call.method}"); @@ -341,8 +340,6 @@ class BackgroundService { ], ); - HttpSSLOptions.apply(); - await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); @@ -441,7 +438,8 @@ class BackgroundService { ), ); - _cancellationToken = CancellationToken(); + _cancellationToken?.complete(); + _cancellationToken = Completer(); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await backupService.backupAsset( @@ -455,7 +453,7 @@ class BackgroundService { isBackground: true, ); - if (!ok && !_cancellationToken!.isCancelled) { + if (!ok && !_cancellationToken!.isCompleted) { unawaited( _showErrorNotification( title: "backup_background_service_error_title".tr(), @@ -467,7 +465,7 @@ class BackgroundService { return ok; } - void _onAssetUploaded({bool shouldNotify = false}) async { + void _onAssetUploaded({bool shouldNotify = false}) { if (!shouldNotify) { return; } diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 539fd1fbd9..9b6a26be03 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; @@ -43,7 +45,6 @@ final backupServiceProvider = Provider( ); class BackupService { - final httpClient = http.Client(); final ApiService _apiService; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; @@ -233,7 +234,7 @@ class BackupService { Future backupAsset( Iterable assets, - http.CancellationToken cancelToken, { + Completer cancelToken, { bool isBackground = false, PMProgressHandler? pmProgressHandler, required void Function(SuccessUploadAsset result) onSuccess, @@ -306,20 +307,20 @@ class BackupService { } final fileStream = file.openRead(); - final assetRawUploadData = http.MultipartFile( + final assetRawUploadData = MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - final baseRequest = MultipartRequest( + final baseRequest = ProgressMultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken.future, onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); - baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String(); @@ -348,7 +349,7 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await NetworkRepository.client.send(baseRequest); final responseBody = jsonDecode(await response.stream.bytesToString()); @@ -398,7 +399,7 @@ class BackupService { await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]); } } - } on http.CancelledException { + } on RequestAbortedException { dPrint(() => "Backup was cancelled by the user"); anyErrors = true; break; @@ -429,26 +430,26 @@ class BackupService { String originalFileName, File? livePhotoVideoFile, MultipartRequest baseRequest, - http.CancellationToken cancelToken, + Completer cancelToken, ) async { if (livePhotoVideoFile == null) { return null; } final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path)); final fileStream = livePhotoVideoFile.openRead(); - final livePhotoRawUploadData = http.MultipartFile( + final livePhotoRawUploadData = MultipartFile( "assetData", fileStream, livePhotoVideoFile.lengthSync(), filename: livePhotoTitle, ); - final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress) + final livePhotoReq = ProgressMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future) ..headers.addAll(baseRequest.headers) ..fields.addAll(baseRequest.fields); livePhotoReq.files.add(livePhotoRawUploadData); - var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken); + var response = await NetworkRepository.client.send(livePhotoReq); var responseBody = jsonDecode(await response.stream.bytesToString()); @@ -470,31 +471,3 @@ class BackupService { AssetType.other => "OTHER", }; } - -class MultipartRequest extends http.MultipartRequest { - /// Creates a new [MultipartRequest]. - MultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - /// Freezes all mutable fields and returns a - /// single-subscription [http.ByteStream] - /// that will emit the request body. - @override - http.ByteStream finalize() { - final byteStream = super.finalize(); - - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return http.ByteStream(stream); - } -} 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/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index cd28942bd2..ce02c9c56b 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -19,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -82,7 +80,7 @@ class ForegroundUploadService { /// Bulk upload of backup candidates from selected albums Future uploadCandidates( String userId, - CancellationToken cancelToken, { + Completer cancelToken, { UploadCallbacks callbacks = const UploadCallbacks(), bool useSequentialUpload = false, }) async { @@ -105,7 +103,7 @@ class ForegroundUploadService { final requireWifi = _shouldRequireWiFi(asset); return requireWifi && !hasWifi; }, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } } @@ -113,37 +111,32 @@ class ForegroundUploadService { /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues Future _uploadSequentially({ required List items, - required CancellationToken cancelToken, + required Completer cancelToken, required bool hasWifi, required UploadCallbacks callbacks, }) async { - final httpClient = Client(); await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - for (final asset in items) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } - - final requireWifi = _shouldRequireWiFi(asset); - if (requireWifi && !hasWifi) { - _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); - continue; - } - - await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks); + for (final asset in items) { + if (shouldAbortUpload || cancelToken.isCompleted) { + break; } - } finally { - httpClient.close(); + + final requireWifi = _shouldRequireWiFi(asset); + if (requireWifi && !hasWifi) { + _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); + continue; + } + + await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks); } } /// Manually upload picked local assets Future uploadManual( - List localAssets, - CancellationToken cancelToken, { + List localAssets, { + Completer? cancelToken, UploadCallbacks callbacks = const UploadCallbacks(), }) async { if (localAssets.isEmpty) { @@ -153,14 +146,14 @@ class ForegroundUploadService { await _executeWithWorkerPool( items: localAssets, cancelToken: cancelToken, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } /// Upload files from shared intent Future uploadShareIntent( List files, { - CancellationToken? cancelToken, + Completer? cancelToken, void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId)? onSuccess, void Function(String fileId, String errorMessage)? onError, @@ -168,20 +161,16 @@ class ForegroundUploadService { if (files.isEmpty) { return; } - - final effectiveCancelToken = cancelToken ?? CancellationToken(); - await _executeWithWorkerPool( items: files, - cancelToken: effectiveCancelToken, - processItem: (file, httpClient) async { + cancelToken: cancelToken, + processItem: (file) async { final fileId = p.hash(file.path).toString(); final result = await _uploadSingleFile( file, deviceAssetId: fileId, - httpClient: httpClient, - cancelToken: effectiveCancelToken, + cancelToken: cancelToken, onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), ); @@ -207,58 +196,49 @@ class ForegroundUploadService { /// [concurrentWorkers] - Number of concurrent workers (default: 3) Future _executeWithWorkerPool({ required List items, - required CancellationToken cancelToken, - required Future Function(T item, Client httpClient) processItem, + required Completer? cancelToken, + required Future Function(T item) processItem, bool Function(T item)? shouldSkip, int concurrentWorkers = 3, }) async { - final httpClients = List.generate(concurrentWorkers, (_) => Client()); - await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - int currentIndex = 0; + int currentIndex = 0; - Future worker(Client httpClient) async { - while (true) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } - - final index = currentIndex; - if (index >= items.length) { - break; - } - currentIndex++; - - final item = items[index]; - - if (shouldSkip?.call(item) ?? false) { - continue; - } - - await processItem(item, httpClient); + Future worker() async { + while (true) { + if (shouldAbortUpload || (cancelToken != null && cancelToken.isCompleted)) { + break; } - } - final workerFutures = >[]; - for (int i = 0; i < concurrentWorkers; i++) { - workerFutures.add(worker(httpClients[i])); - } + final index = currentIndex; + if (index >= items.length) { + break; + } + currentIndex++; - await Future.wait(workerFutures); - } finally { - for (final client in httpClients) { - client.close(); + final item = items[index]; + + if (shouldSkip?.call(item) ?? false) { + continue; + } + + await processItem(item); } } + + final workerFutures = >[]; + for (int i = 0; i < concurrentWorkers; i++) { + workerFutures.add(worker()); + } + + await Future.wait(workerFutures); } Future _uploadSingleAsset( LocalAsset asset, - Client httpClient, - CancellationToken cancelToken, { + Completer? cancelToken, { required UploadCallbacks callbacks, }) async { File? file; @@ -343,7 +323,6 @@ class ForegroundUploadService { final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final deviceId = Store.get(StoreKey.deviceId); - final headers = ApiService.getRequestHeaders(); final fields = { 'deviceAssetId': asset.localId!, 'deviceId': deviceId, @@ -358,15 +337,15 @@ class ForegroundUploadService { if (entity.isLivePhoto && livePhotoFile != null) { final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); + final onProgress = callbacks.onProgress; final livePhotoResult = await _uploadRepository.uploadFile( file: livePhotoFile, originalFileName: livePhotoTitle, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: (bytes, totalBytes) => - callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), + onProgress: onProgress != null + ? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes) + : null, logContext: 'livePhotoVideo[${asset.localId}]', ); @@ -395,15 +374,15 @@ class ForegroundUploadService { ]); } + final onProgress = callbacks.onProgress; final result = await _uploadRepository.uploadFile( file: file, originalFileName: originalFileName, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: (bytes, totalBytes) => - callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), + onProgress: onProgress != null + ? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes) + : null, logContext: 'asset[${asset.localId}]', ); @@ -442,8 +421,7 @@ class ForegroundUploadService { Future _uploadSingleFile( File file, { required String deviceAssetId, - required Client httpClient, - required CancellationToken cancelToken, + required Completer? cancelToken, void Function(int bytes, int totalBytes)? onProgress, }) async { try { @@ -452,12 +430,9 @@ class ForegroundUploadService { final fileModifiedAt = stats.modified; final filename = p.basename(file.path); - final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); - final fields = { 'deviceAssetId': deviceAssetId, - 'deviceId': deviceId, + 'deviceId': Store.get(StoreKey.deviceId), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'isFavorite': 'false', @@ -467,11 +442,9 @@ class ForegroundUploadService { return await _uploadRepository.uploadFile( file: file, originalFileName: filename, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: onProgress ?? (_, __) {}, + onProgress: onProgress, logContext: 'shareIntent[$deviceAssetId]', ); } catch (e) { diff --git a/mobile/lib/services/share.service.dart b/mobile/lib/services/share.service.dart index 06a4a192d4..a0998d6d3d 100644 --- a/mobile/lib/services/share.service.dart +++ b/mobile/lib/services/share.service.dart @@ -6,12 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -import 'api.service.dart'; - final shareServiceProvider = Provider((ref) => ShareService(ref.watch(apiServiceProvider))); class ShareService { 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/cache/widgets_binding.dart b/mobile/lib/utils/cache/widgets_binding.dart index 2749a54d97..9f583d3220 100644 --- a/mobile/lib/utils/cache/widgets_binding.dart +++ b/mobile/lib/utils/cache/widgets_binding.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; - -import 'custom_image_cache.dart'; +import 'package:immich_mobile/utils/cache/custom_image_cache.dart'; final class ImmichWidgetsBinding extends WidgetsFlutterBinding { @override diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart deleted file mode 100644 index 907fbad102..0000000000 --- a/mobile/lib/utils/hooks/interval_hook.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter_hooks/flutter_hooks.dart'; - -// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638 -void useInterval(Duration delay, VoidCallback callback) { - final savedCallback = useRef(callback); - savedCallback.value = callback; - - useEffect(() { - final timer = Timer.periodic(delay, (_) => savedCallback.value()); - return timer.cancel; - }, [delay]); -} diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart deleted file mode 100644 index a4c97a532f..0000000000 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:logging/logging.dart'; - -class HttpSSLCertOverride extends HttpOverrides { - static final Logger _log = Logger("HttpSSLCertOverride"); - final bool _allowSelfSignedSSLCert; - final String? _serverHost; - final SSLClientCertStoreVal? _clientCert; - late final SecurityContext? _ctxWithCert; - - HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) { - if (_clientCert != null) { - _ctxWithCert = SecurityContext(withTrustedRoots: true); - if (_ctxWithCert != null) { - setClientCert(_ctxWithCert, _clientCert); - } else { - _log.severe("Failed to create security context with client cert!"); - } - } else { - _ctxWithCert = null; - } - } - - static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) { - try { - _log.info("Setting client certificate"); - ctx.usePrivateKeyBytes(cert.data, password: cert.password); - ctx.useCertificateChainBytes(cert.data, password: cert.password); - } catch (e) { - _log.severe("Failed to set SSL client cert: $e"); - return false; - } - return true; - } - - @override - HttpClient createHttpClient(SecurityContext? context) { - if (context != null) { - if (_clientCert != null) { - setClientCert(context, _clientCert); - } - } else { - context = _ctxWithCert; - } - - return super.createHttpClient(context) - ..badCertificateCallback = (X509Certificate cert, String host, int port) { - if (_allowSelfSignedSSLCert) { - // Conduct server host checks if user is logged in to avoid making - // insecure SSL connections to services that are not the immich server. - if (_serverHost == null || _serverHost.contains(host)) { - return true; - } - } - _log.severe("Invalid SSL certificate for $host:$port"); - return false; - }; - } -} diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart deleted file mode 100644 index a93387c9db..0000000000 --- a/mobile/lib/utils/http_ssl_options.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; - -class HttpSSLOptions { - static void apply() { - AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; - bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - return _apply(allowSelfSignedSSLCert); - } - - static void applyFromSettings(bool newValue) => _apply(newValue); - - static void _apply(bool allowSelfSignedSSLCert) { - String? serverHost; - if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) { - serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; - } - - SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load(); - - HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); - } -} diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 7ac120acb4..c8224b9c55 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:logging/logging.dart'; import 'package:worker_manager/worker_manager.dart'; @@ -54,7 +53,6 @@ Cancelable runInIsolateGentle({ Logger log = Logger("IsolateLogger"); try { - HttpSSLOptions.apply(); result = await computation(ref); } on CanceledError { log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 30a46daa56..76916cee1e 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; @@ -17,14 +18,17 @@ import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; 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'; @@ -33,7 +37,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 22; +const int targetVersion = 25; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -99,6 +103,24 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { } } + if (version < 23 && Store.isBetaTimelineEnabled) { + await _populateLocalAssetPlaybackStyle(drift); + } + + if (version < 24 && Store.isBetaTimelineEnabled) { + 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); } @@ -392,6 +414,68 @@ Future migrateStoreToIsar(Isar db, Drift drift) async { } } +Future _populateLocalAssetPlaybackStyle(Drift db) async { + try { + final nativeApi = NativeSyncApi(); + + final albums = await nativeApi.getAlbums(); + for (final album in albums) { + final assets = await nativeApi.getAssetsForAlbum(album.id); + await db.batch((batch) { + for (final asset in assets) { + batch.update( + db.localAssetEntity, + LocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))), + where: (t) => t.id.equals(asset.id), + ); + } + }); + } + + if (Platform.isAndroid) { + final trashedAssetMap = await nativeApi.getTrashedAssets(); + for (final entry in trashedAssetMap.cast>().entries) { + final assets = entry.value.cast(); + await db.batch((batch) { + for (final asset in assets) { + batch.update( + db.trashedLocalAssetEntity, + TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))), + where: (t) => t.id.equals(asset.id), + ); + } + }); + } + dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets"); + } else { + dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local assets"); + } + } catch (error) { + dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error"); + } +} + +Future _applyLocalAssetOrientation(Drift db) { + final query = db.localAssetEntity.update() + ..where((filter) => (filter.orientation.equals(90) | (filter.orientation.equals(270)))); + return query.write( + LocalAssetEntityCompanion.custom( + width: db.localAssetEntity.height, + height: db.localAssetEntity.width, + orientation: const Variable(0), + ), + ); +} + +AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) { + PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown, + PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image, + PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video, + PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated, + PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto, + PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping, +}; + class _DeviceAsset { final String assetId; final List? hash; diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 7db03a33aa..c323c573b4 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -23,17 +23,16 @@ import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; +import 'package:immich_mobile/widgets/asset_grid/disable_multi_select_button.dart'; +import 'package:immich_mobile/widgets/asset_grid/draggable_scrollbar_custom.dart'; +import 'package:immich_mobile/widgets/asset_grid/group_divider_title.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -import 'asset_grid_data_structure.dart'; -import 'disable_multi_select_button.dart'; -import 'draggable_scrollbar_custom.dart'; -import 'group_divider_title.dart'; - typedef ImmichAssetGridSelectionListener = void Function(bool, Set); class ImmichAssetGridView extends ConsumerStatefulWidget { diff --git a/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart b/mobile/lib/widgets/asset_grid/permanent_delete_dialog.dart similarity index 92% rename from mobile/lib/widgets/asset_grid/trash_delete_dialog.dart rename to mobile/lib/widgets/asset_grid/permanent_delete_dialog.dart index eaca04b481..18265b8d46 100644 --- a/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart +++ b/mobile/lib/widgets/asset_grid/permanent_delete_dialog.dart @@ -3,8 +3,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_ui/immich_ui.dart'; -class TrashDeleteDialog extends StatelessWidget { - const TrashDeleteDialog({super.key, required this.count}); +class PermanentDeleteDialog extends StatelessWidget { + const PermanentDeleteDialog({super.key, required this.count}); final int count; 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/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 5707e3678f..22a7deffff 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget { padding: const EdgeInsets.only(top: 40.0), child: Column( children: [ - if (asset.isVideo) const VideoControls(), + if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()), BottomNavigationBar( elevation: 0.0, backgroundColor: Colors.transparent, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 0e766c77b9..09c0e9d091 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_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 CustomVideoPlayerControls extends HookConsumerWidget { + final String videoId; final Duration hideTimerDuration; - const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); + const CustomVideoPlayerControls({ + super.key, + required this.videoId, + this.hideTimerDuration = const Duration(seconds: 5), + }); @override Widget build(BuildContext context, WidgetRef ref) { final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo)); final showControls = ref.watch(showControlsProvider); - final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status)); final cast = ref.watch(castProvider); @@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget { if (!context.mounted) { return; } - final state = ref.read(videoPlaybackValueProvider).state; + final s = ref.read(videoPlayerProvider(videoId)).status; // Do not hide on paused - if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { + if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) { ref.read(showControlsProvider.notifier).show = false; } }); - final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; + final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ref.read(showControlsProvider.notifier).show = true; } - // When we change position, show or hide timer - ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { - showControlsAndStartHideTimer(); + // When playback starts, reset the hide timer + ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) { + if (next == VideoPlaybackStatus.playing) { + hideTimer.reset(); + } }); /// Toggles between playing and pausing depending on the state of the video @@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget { return; } - if (state == VideoPlaybackState.playing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); - } else if (state == VideoPlaybackState.completed) { - ref.read(videoPlayerControlsProvider.notifier).restart(); + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + if (status == VideoPlaybackStatus.playing) { + notifier.pause(); + } else if (status == VideoPlaybackStatus.completed) { + notifier.restart(); } else { - ref.read(videoPlayerControlsProvider.notifier).play(); + notifier.play(); } } @@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { child: CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, + isFinished: status == VideoPlaybackStatus.completed, isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), show: assetIsVideo && showControls, onPressed: togglePlay, ), 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 42f6078478..85707c82ea 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -1,17 +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 [videoPlayerControlsProvider] -class VideoControls extends ConsumerWidget { - const VideoControls({super.key}); +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 - ? const VideoPosition() - : const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition()); + 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 9d9e2821ad..0000000000 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ /dev/null @@ -1,112 +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_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; - -class VideoPosition extends HookConsumerWidget { - const VideoPosition({super.key}); - - @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(videoPlaybackValueProvider.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 state = ref.read(videoPlaybackValueProvider).state; - wasPlaying.value = state != VideoPlaybackState.paused; - ref.read(videoPlayerControlsProvider.notifier).pause(); - }, - onChangeEnd: (value) { - if (wasPlaying.value) { - ref.read(videoPlayerControlsProvider.notifier).play(); - } - }, - onChanged: (value) { - final seekToDuration = (duration * (value / 100.0)); - - if (isCasting) { - ref.read(castProvider.notifier).seekTo(seekToDuration); - return; - } - - ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration; - - // This immediately updates the slider position without waiting for the video to update - ref.read(videoPlaybackValueProvider.notifier).position = 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/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 541b7c28c3..cb429c9f48 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -62,7 +62,7 @@ class ImmichSliverAppBar extends ConsumerWidget { pinned: pinned, snap: snap, expandedHeight: expandedHeight, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))), automaticallyImplyLeading: false, centerTitle: false, title: title ?? const _ImmichLogoWithText(), diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 727950fd86..4cba83bea7 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget { if (memories[memoryIndex].assets.isNotEmpty) { final asset = memories[memoryIndex].assets[0]; ref.read(currentAssetProvider.notifier).set(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } } context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex)); }, diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index 72c4766c45..2f775f57e2 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -420,7 +420,11 @@ class PhotoViewCoreState extends State Widget _buildChild() { return widget.hasCustomChild - ? widget.customChild! + ? SizedBox( + width: scaleBoundaries.childSize.width * scale, + height: scaleBoundaries.childSize.height * scale, + child: widget.customChild!, + ) : Image( key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null, image: widget.imageProvider!, @@ -428,7 +432,7 @@ class PhotoViewCoreState extends State gaplessPlayback: widget.gaplessPlayback ?? false, filterQuality: widget.filterQuality, width: scaleBoundaries.childSize.width * scale, - fit: BoxFit.cover, + fit: BoxFit.contain, isAntiAlias: widget.filterQuality == FilterQuality.high, ); } diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 6cbcec8d82..7b55e0e37e 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -1,7 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; - -import 'photo_view_hit_corners.dart'; +import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_hit_corners.dart'; /// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c) /// for the gist diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index ee18668f52..d8d2ae7ee5 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -1,9 +1,8 @@ import 'package:flutter/widgets.dart'; - -import '../photo_view.dart'; -import 'core/photo_view_core.dart'; -import 'photo_view_default_widgets.dart'; -import 'utils/photo_view_utils.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; +import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart'; +import 'package:immich_mobile/widgets/photo_view/src/photo_view_default_widgets.dart'; +import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart'; class ImageWrapper extends StatefulWidget { const ImageWrapper({ diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index e86d313294..d5905a246c 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -10,12 +10,10 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; @@ -31,15 +29,12 @@ class AdvancedSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - bool isLoggedIn = ref.read(currentUserProvider) != null; - final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); - final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); @@ -120,15 +115,8 @@ class AdvancedSettings extends HookConsumerWidget { subtitle: "advanced_settings_prefer_remote_subtitle".tr(), ), if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(), - SettingsSwitchListTile( - enabled: !isLoggedIn, - valueNotifier: allowSelfSignedSSLCert, - title: "advanced_settings_self_signed_ssl_title".tr(), - subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(), - onChanged: HttpSSLOptions.applyFromSettings, - ), const CustomProxyHeaderSettings(), - SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), + const SslClientCertSettings(), if (!Store.isBetaTimelineEnabled) SettingsSwitchListTile( valueNotifier: useAlternatePMFilter, diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 907cd19843..82394bdc07 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -6,11 +6,10 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; +import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'asset_list_layout_settings.dart'; - class AssetListSettings extends HookConsumerWidget { const AssetListSettings({super.key}); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index 1555790ff9..a2bca2745f 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; -import 'video_viewer_settings.dart'; class AssetViewerSettings extends StatelessWidget { const AssetViewerSettings({super.key}); diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index ef571fb30a..92787077a1 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -5,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -87,25 +89,27 @@ class SyncStatusAndActions extends HookConsumerWidget { context: context, builder: (context) { return AlertDialog( - title: Text("reset_sqlite".t(context: context)), - content: Text("reset_sqlite_confirmation".t(context: context)), + title: Text(context.t.reset_sqlite), + content: Text(context.t.reset_sqlite_confirmation), actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text("cancel".t(context: context)), - ), + TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel)), TextButton( onPressed: () async { await ref.read(driftProvider).reset(); context.pop(); - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("reset_sqlite_success".t(context: context))), + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(context.t.reset_sqlite_success), + content: Text(context.t.reset_sqlite_done), + actions: [TextButton(onPressed: () => ctx.pop(), child: Text(context.t.ok))], + ), + ), ); }, - child: Text( - "confirm".t(context: context), - style: TextStyle(color: context.colorScheme.error), - ), + child: Text(context.t.confirm, style: TextStyle(color: context.colorScheme.error)), ), ], ); diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index fa210ee720..77ad7ee179 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -1,18 +1,16 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:logging/logging.dart'; class SslClientCertSettings extends StatefulWidget { - const SslClientCertSettings({super.key, required this.isLoggedIn}); - - final bool isLoggedIn; + const SslClientCertSettings({super.key}); @override State createState() => _SslClientCertSettingsState(); @@ -21,9 +19,24 @@ class SslClientCertSettings extends StatefulWidget { class _SslClientCertSettingsState extends State { final _log = Logger("SslClientCertSettings"); - bool isCertExist; + bool isCertExist = false; - _SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; + @override + void initState() { + super.initState(); + unawaited(_checkCertificate()); + } + + Future _checkCertificate() async { + try { + final exists = await networkApi.hasCertificate(); + if (mounted && exists != isCertExist) { + setState(() => isCertExist = exists); + } + } catch (e) { + _log.warning("Failed to check certificate existence", e); + } + } @override Widget build(BuildContext context) { @@ -45,11 +58,8 @@ class _SslClientCertSettingsState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ - ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), - ElevatedButton( - onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert, - child: Text("remove".tr()), - ), + ElevatedButton(onPressed: importCert, child: Text("client_cert_import".tr())), + ElevatedButton(onPressed: !isCertExist ? null : removeCert, child: Text("remove".tr())), ], ), ], @@ -74,9 +84,7 @@ class _SslClientCertSettingsState extends State { cancel: "cancel".tr(), confirm: "confirm".tr(), ); - final cert = await networkApi.selectCertificate(styling); - await SSLClientCertStoreVal(cert.data, cert.password).save(); - HttpSSLOptions.apply(); + await networkApi.selectCertificate(styling); setState(() => isCertExist = true); showMessage("client_cert_import_success_msg".tr()); } catch (e) { @@ -91,8 +99,6 @@ class _SslClientCertSettingsState extends State { Future removeCert() async { try { await networkApi.removeCertificate(); - await SSLClientCertStoreVal.delete(); - HttpSSLOptions.apply(); setState(() => isCertExist = false); showMessage("client_cert_remove_msg".tr()); } catch (e) { diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 1b8ed3d9e4..085958de66 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -410,7 +410,7 @@ class SearchApi { /// Filter by person IDs /// /// * [num] rating: - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// * [num] size: /// Number of results to return @@ -633,7 +633,7 @@ class SearchApi { /// Filter by person IDs /// /// * [num] rating: - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// * [num] size: /// Number of results to return 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/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 2afcea20ff..f82c362ff7 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -30,6 +30,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -63,7 +66,7 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -77,6 +80,9 @@ class TimelineApi { if (albumId != null) { queryParams.addAll(_queryParams('', 'albumId', albumId)); } + if (bbox != null) { + queryParams.addAll(_queryParams('', 'bbox', bbox)); + } if (isFavorite != null) { queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); } @@ -141,6 +147,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -174,8 +183,8 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -200,6 +209,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -233,7 +245,7 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -247,6 +259,9 @@ class TimelineApi { if (albumId != null) { queryParams.addAll(_queryParams('', 'albumId', albumId)); } + if (bbox != null) { + queryParams.addAll(_queryParams('', 'bbox', bbox)); + } if (isFavorite != null) { queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); } @@ -307,6 +322,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -340,8 +358,8 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo( albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index a373743852..99bac7abfa 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -86,16 +86,10 @@ class AssetBulkUpdateDto { /// num? longitude; - /// Rating + /// Rating in range [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Time zone (IANA timezone) @@ -223,7 +217,9 @@ class AssetBulkUpdateDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), timeZone: mapValueOfType(json, r'timeZone'), visibility: AssetVisibility.fromJson(json[r'visibility']), ); diff --git a/mobile/openapi/lib/model/audio_codec.dart b/mobile/openapi/lib/model/audio_codec.dart index 095c616995..be1ff0dcb9 100644 --- a/mobile/openapi/lib/model/audio_codec.dart +++ b/mobile/openapi/lib/model/audio_codec.dart @@ -26,6 +26,7 @@ class AudioCodec { static const mp3 = AudioCodec._(r'mp3'); static const aac = AudioCodec._(r'aac'); static const libopus = AudioCodec._(r'libopus'); + static const opus = AudioCodec._(r'opus'); static const pcmS16le = AudioCodec._(r'pcm_s16le'); /// List of all possible values in this [enum][AudioCodec]. @@ -33,6 +34,7 @@ class AudioCodec { mp3, aac, libopus, + opus, pcmS16le, ]; @@ -75,6 +77,7 @@ class AudioCodecTypeTransformer { case r'mp3': return AudioCodec.mp3; case r'aac': return AudioCodec.aac; case r'libopus': return AudioCodec.libopus; + case r'opus': return AudioCodec.opus; case r'pcm_s16le': return AudioCodec.pcmS16le; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 4a7ca403ab..81f8d41527 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -256,16 +256,10 @@ class MetadataSearchDto { /// String? previewPath; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Number of results to return @@ -754,7 +748,9 @@ class MetadataSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], previewPath: mapValueOfType(json, r'previewPath'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 7e0fb0c5c2..4166fc9f3c 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -159,16 +159,10 @@ class RandomSearchDto { /// Filter by person IDs List personIds; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Number of results to return @@ -565,7 +559,9 @@ class RandomSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 7d43cea872..5f8214467f 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -199,16 +199,10 @@ class SmartSearchDto { /// String? queryAssetId; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Number of results to return @@ -605,7 +599,9 @@ class SmartSearchDto { : const [], query: mapValueOfType(json, r'query'), queryAssetId: mapValueOfType(json, r'queryAssetId'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart index fce2feb421..d5bbf448a3 100644 --- a/mobile/openapi/lib/model/statistics_search_dto.dart +++ b/mobile/openapi/lib/model/statistics_search_dto.dart @@ -164,16 +164,10 @@ class StatisticsSearchDto { /// Filter by person IDs List personIds; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Filter by state/province name @@ -495,7 +489,9 @@ class StatisticsSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 42e8ec387f..8526995934 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -71,16 +71,10 @@ class UpdateAssetDto { /// num? longitude; - /// Rating + /// Rating in range [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Asset visibility @@ -178,7 +172,9 @@ class UpdateAssetDto { latitude: num.parse('${json[r'latitude']}'), livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), longitude: num.parse('${json[r'longitude']}'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), visibility: AssetVisibility.fromJson(json[r'visibility']), ); } diff --git a/mobile/packages/ui/lib/src/components/close_button.dart b/mobile/packages/ui/lib/src/components/close_button.dart index 9308fdaadb..d23c309c91 100644 --- a/mobile/packages/ui/lib/src/components/close_button.dart +++ b/mobile/packages/ui/lib/src/components/close_button.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:immich_ui/src/components/icon_button.dart'; import 'package:immich_ui/src/types.dart'; -import 'icon_button.dart'; - class ImmichCloseButton extends StatelessWidget { final VoidCallback? onPressed; final ImmichVariant variant; diff --git a/mobile/pigeon/local_image_api.dart b/mobile/pigeon/local_image_api.dart index 35b6734568..eb538d7b1a 100644 --- a/mobile/pigeon/local_image_api.dart +++ b/mobile/pigeon/local_image_api.dart @@ -21,6 +21,7 @@ abstract class LocalImageApi { required int width, required int height, required bool isVideo, + required bool preferEncoded, }); void cancelRequest(int requestId); diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index ae82018b02..cd55addd99 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -11,6 +11,15 @@ import 'package:pigeon/pigeon.dart'; dartPackageName: 'immich_mobile', ), ) +enum PlatformAssetPlaybackStyle { + unknown, + image, + video, + imageAnimated, + livePhoto, + videoLooping, +} + class PlatformAsset { final String id; final String name; @@ -31,6 +40,8 @@ class PlatformAsset { final double? latitude; final double? longitude; + final PlatformAssetPlaybackStyle playbackStyle; + const PlatformAsset({ required this.id, required this.name, @@ -45,6 +56,7 @@ class PlatformAsset { this.adjustmentTime, this.latitude, this.longitude, + this.playbackStyle = PlatformAssetPlaybackStyle.unknown, }); } diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index 68d2f7d8fc..704efed770 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -34,8 +34,14 @@ abstract class NetworkApi { void addCertificate(ClientCertData clientData); @async - ClientCertData selectCertificate(ClientCertPrompt promptText); + void selectCertificate(ClientCertPrompt promptText); @async void removeCertificate(); + + bool hasCertificate(); + + int getClientPointer(); + + void setRequestHeaders(Map headers, List serverUrls, String? token); } diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index 749deb828e..7f0135acb8 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart'; dartOut: 'lib/platform/remote_image_api.g.dart', swiftOut: 'ios/Runner/Images/RemoteImages.g.swift', swiftOptions: SwiftOptions(includeErrorClass: false), - kotlinOut: - 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false), dartOptions: DartOptions(), dartPackageName: 'immich_mobile', @@ -15,11 +14,7 @@ import 'package:pigeon/pigeon.dart'; @HostApi() abstract class RemoteImageApi { @async - Map? requestImage( - String url, { - required Map headers, - required int requestId, - }); + Map? requestImage(String url, {required int requestId, required bool preferEncoded}); void cancelRequest(int requestId); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 28adfc2ab7..9d5f431792 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -201,22 +201,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.5" - cancellation_token: - dependency: transitive - description: - name: cancellation_token - sha256: ad95acf9d4b2f3563e25dc937f63587e46a70ce534e910b65d10e115490f1027 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - cancellation_token_http: - dependency: "direct main" - description: - name: cancellation_token_http - sha256: "0fff478fe5153700396b3472ddf93303c219f1cb8d8e779e65b014cb9c7f0213" - url: "https://pub.dev" - source: hosted - version: "2.1.0" cast: dependency: "direct main" description: @@ -313,14 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cronet_http: - dependency: "direct main" - description: - name: cronet_http - sha256: "1fff7f26ac0c4cda97fe2a9aa082494baee4775f167c27ba45f6c8e88571e3ab" - url: "https://pub.dev" - source: hosted - version: "1.7.0" crop_image: dependency: "direct main" description: @@ -356,11 +332,12 @@ packages: cupertino_http: dependency: "direct main" description: - name: cupertino_http - sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" - url: "https://pub.dev" - source: hosted - version: "2.4.0" + path: "pkgs/cupertino_http" + ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c + resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c + url: "https://github.com/mertalev/http" + source: git + version: "3.0.0-wip" custom_lint: dependency: "direct dev" description: @@ -1241,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: e132bc3 - resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e + ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 + resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" @@ -1286,6 +1263,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + ok_http: + dependency: "direct main" + description: + path: "pkgs/ok_http" + ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af" + resolved-ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af" + url: "https://github.com/mertalev/http" + source: git + version: "0.1.1-wip" openapi: dependency: "direct main" description: @@ -1741,19 +1727,20 @@ packages: socket_io_client: dependency: "direct main" description: - name: socket_io_client - sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b - url: "https://pub.dev" - source: hosted - version: "2.0.3+1" + path: "." + ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006 + resolved-ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006 + url: "https://github.com/mertalev/socket.io-client-dart" + source: git + version: "3.1.4" socket_io_common: dependency: transitive description: name: socket_io_common - sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.1.1" source_gen: dependency: transitive description: @@ -2115,21 +2102,21 @@ packages: source: hosted version: "1.1.1" web_socket: - dependency: transitive + dependency: "direct main" description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webdriver: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0b54dfc53e..77955c06ab 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: async: ^2.13.0 auto_route: ^9.2.0 background_downloader: ^9.3.0 - cancellation_token_http: ^2.1.0 cast: ^2.1.0 collection: ^1.19.1 connectivity_plus: ^6.1.3 @@ -57,7 +56,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: 'e132bc3' + ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: @@ -76,7 +75,6 @@ dependencies: share_handler: ^0.0.25 share_plus: ^10.1.4 sliver_tools: ^0.2.12 - socket_io_client: ^2.0.3+1 stream_transform: ^2.1.1 thumbhash: 0.1.0+1 timezone: ^0.9.4 @@ -84,8 +82,21 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.3.0 worker_manager: ^7.2.7 - cronet_http: ^1.7.0 - cupertino_http: ^2.4.0 + web_socket: ^1.0.1 + socket_io_client: + git: + url: https://github.com/mertalev/socket.io-client-dart + ref: 'e1d813a240b5d5b7e2f141b2b605c5429b7cd006' # https://github.com/rikulo/socket.io-client-dart/pull/435 + cupertino_http: + git: + url: https://github.com/mertalev/http + ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876 + path: pkgs/cupertino_http/ + ok_http: + git: + url: https://github.com/mertalev/http + ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877 + path: pkgs/ok_http/ dev_dependencies: auto_route_generator: ^9.0.0 diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index 17d02581d1..df65fa3306 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -131,6 +131,7 @@ void main() { durationInSeconds: 0, orientation: 0, isFavorite: false, + playbackStyle: PlatformAssetPlaybackStyle.image ); final assetsToRestore = [LocalAssetStub.image1]; @@ -214,6 +215,7 @@ void main() { isFavorite: false, createdAt: 1700000000, updatedAt: 1732000000, + playbackStyle: PlatformAssetPlaybackStyle.image ); final localAsset = platformAsset.toLocalAsset(); diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 153697896a..37f5ef1021 100644 --- a/mobile/test/drift/main/generated/schema.dart +++ b/mobile/test/drift/main/generated/schema.dart @@ -24,6 +24,7 @@ import 'schema_v18.dart' as v18; import 'schema_v19.dart' as v19; import 'schema_v20.dart' as v20; import 'schema_v21.dart' as v21; +import 'schema_v22.dart' as v22; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -71,6 +72,8 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v20.DatabaseAtV20(db); case 21: return v21.DatabaseAtV21(db); + case 22: + return v22.DatabaseAtV22(db); default: throw MissingSchemaException(version, versions); } @@ -98,5 +101,6 @@ class GeneratedHelper implements SchemaInstantiationHelper { 19, 20, 21, + 22, ]; } diff --git a/mobile/test/drift/main/generated/schema_v21.dart b/mobile/test/drift/main/generated/schema_v21.dart index 4e18673b38..846eb4aabc 100644 --- a/mobile/test/drift/main/generated/schema_v21.dart +++ b/mobile/test/drift/main/generated/schema_v21.dart @@ -1553,6 +1553,14 @@ class LocalAssetEntity extends Table type: DriftSqlType.double, requiredDuringInsert: false, ); + late final GeneratedColumn playbackStyle = GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); @override List get $columns => [ name, @@ -1570,6 +1578,7 @@ class LocalAssetEntity extends Table adjustmentTime, latitude, longitude, + playbackStyle, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1642,6 +1651,10 @@ class LocalAssetEntity extends Table DriftSqlType.double, data['${effectivePrefix}longitude'], ), + playbackStyle: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, ); } @@ -1673,6 +1686,7 @@ class LocalAssetEntityData extends DataClass final DateTime? adjustmentTime; final double? latitude; final double? longitude; + final int playbackStyle; const LocalAssetEntityData({ required this.name, required this.type, @@ -1689,6 +1703,7 @@ class LocalAssetEntityData extends DataClass this.adjustmentTime, this.latitude, this.longitude, + required this.playbackStyle, }); @override Map toColumns(bool nullToAbsent) { @@ -1724,6 +1739,7 @@ class LocalAssetEntityData extends DataClass if (!nullToAbsent || longitude != null) { map['longitude'] = Variable(longitude); } + map['playback_style'] = Variable(playbackStyle); return map; } @@ -1748,6 +1764,7 @@ class LocalAssetEntityData extends DataClass adjustmentTime: serializer.fromJson(json['adjustmentTime']), latitude: serializer.fromJson(json['latitude']), longitude: serializer.fromJson(json['longitude']), + playbackStyle: serializer.fromJson(json['playbackStyle']), ); } @override @@ -1769,6 +1786,7 @@ class LocalAssetEntityData extends DataClass 'adjustmentTime': serializer.toJson(adjustmentTime), 'latitude': serializer.toJson(latitude), 'longitude': serializer.toJson(longitude), + 'playbackStyle': serializer.toJson(playbackStyle), }; } @@ -1788,6 +1806,7 @@ class LocalAssetEntityData extends DataClass Value adjustmentTime = const Value.absent(), Value latitude = const Value.absent(), Value longitude = const Value.absent(), + int? playbackStyle, }) => LocalAssetEntityData( name: name ?? this.name, type: type ?? this.type, @@ -1808,6 +1827,7 @@ class LocalAssetEntityData extends DataClass : this.adjustmentTime, latitude: latitude.present ? latitude.value : this.latitude, longitude: longitude.present ? longitude.value : this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, ); LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { return LocalAssetEntityData( @@ -1834,6 +1854,9 @@ class LocalAssetEntityData extends DataClass : this.adjustmentTime, latitude: data.latitude.present ? data.latitude.value : this.latitude, longitude: data.longitude.present ? data.longitude.value : this.longitude, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, ); } @@ -1854,7 +1877,8 @@ class LocalAssetEntityData extends DataClass ..write('iCloudId: $iCloudId, ') ..write('adjustmentTime: $adjustmentTime, ') ..write('latitude: $latitude, ') - ..write('longitude: $longitude') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } @@ -1876,6 +1900,7 @@ class LocalAssetEntityData extends DataClass adjustmentTime, latitude, longitude, + playbackStyle, ); @override bool operator ==(Object other) => @@ -1895,7 +1920,8 @@ class LocalAssetEntityData extends DataClass other.iCloudId == this.iCloudId && other.adjustmentTime == this.adjustmentTime && other.latitude == this.latitude && - other.longitude == this.longitude); + other.longitude == this.longitude && + other.playbackStyle == this.playbackStyle); } class LocalAssetEntityCompanion extends UpdateCompanion { @@ -1914,6 +1940,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion { final Value adjustmentTime; final Value latitude; final Value longitude; + final Value playbackStyle; const LocalAssetEntityCompanion({ this.name = const Value.absent(), this.type = const Value.absent(), @@ -1930,6 +1957,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion { this.adjustmentTime = const Value.absent(), this.latitude = const Value.absent(), this.longitude = const Value.absent(), + this.playbackStyle = const Value.absent(), }); LocalAssetEntityCompanion.insert({ required String name, @@ -1947,6 +1975,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion { this.adjustmentTime = const Value.absent(), this.latitude = const Value.absent(), this.longitude = const Value.absent(), + this.playbackStyle = const Value.absent(), }) : name = Value(name), type = Value(type), id = Value(id); @@ -1966,6 +1995,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion { Expression? adjustmentTime, Expression? latitude, Expression? longitude, + Expression? playbackStyle, }) { return RawValuesInsertable({ if (name != null) 'name': name, @@ -1983,6 +2013,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion { if (adjustmentTime != null) 'adjustment_time': adjustmentTime, if (latitude != null) 'latitude': latitude, if (longitude != null) 'longitude': longitude, + if (playbackStyle != null) 'playback_style': playbackStyle, }); } @@ -2002,6 +2033,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion { Value? adjustmentTime, Value? latitude, Value? longitude, + Value? playbackStyle, }) { return LocalAssetEntityCompanion( name: name ?? this.name, @@ -2019,6 +2051,7 @@ class LocalAssetEntityCompanion extends UpdateCompanion { adjustmentTime: adjustmentTime ?? this.adjustmentTime, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, ); } @@ -2070,6 +2103,9 @@ class LocalAssetEntityCompanion extends UpdateCompanion { if (longitude.present) { map['longitude'] = Variable(longitude.value); } + if (playbackStyle.present) { + map['playback_style'] = Variable(playbackStyle.value); + } return map; } @@ -2090,7 +2126,8 @@ class LocalAssetEntityCompanion extends UpdateCompanion { ..write('iCloudId: $iCloudId, ') ..write('adjustmentTime: $adjustmentTime, ') ..write('latitude: $latitude, ') - ..write('longitude: $longitude') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } @@ -7820,6 +7857,14 @@ class TrashedLocalAssetEntity extends Table type: DriftSqlType.int, requiredDuringInsert: true, ); + late final GeneratedColumn playbackStyle = GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); @override List get $columns => [ name, @@ -7835,6 +7880,7 @@ class TrashedLocalAssetEntity extends Table isFavorite, orientation, source, + playbackStyle, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -7902,6 +7948,10 @@ class TrashedLocalAssetEntity extends Table DriftSqlType.int, data['${effectivePrefix}source'], )!, + playbackStyle: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, ); } @@ -7931,6 +7981,7 @@ class TrashedLocalAssetEntityData extends DataClass final bool isFavorite; final int orientation; final int source; + final int playbackStyle; const TrashedLocalAssetEntityData({ required this.name, required this.type, @@ -7945,6 +7996,7 @@ class TrashedLocalAssetEntityData extends DataClass required this.isFavorite, required this.orientation, required this.source, + required this.playbackStyle, }); @override Map toColumns(bool nullToAbsent) { @@ -7970,6 +8022,7 @@ class TrashedLocalAssetEntityData extends DataClass map['is_favorite'] = Variable(isFavorite); map['orientation'] = Variable(orientation); map['source'] = Variable(source); + map['playback_style'] = Variable(playbackStyle); return map; } @@ -7992,6 +8045,7 @@ class TrashedLocalAssetEntityData extends DataClass isFavorite: serializer.fromJson(json['isFavorite']), orientation: serializer.fromJson(json['orientation']), source: serializer.fromJson(json['source']), + playbackStyle: serializer.fromJson(json['playbackStyle']), ); } @override @@ -8011,6 +8065,7 @@ class TrashedLocalAssetEntityData extends DataClass 'isFavorite': serializer.toJson(isFavorite), 'orientation': serializer.toJson(orientation), 'source': serializer.toJson(source), + 'playbackStyle': serializer.toJson(playbackStyle), }; } @@ -8028,6 +8083,7 @@ class TrashedLocalAssetEntityData extends DataClass bool? isFavorite, int? orientation, int? source, + int? playbackStyle, }) => TrashedLocalAssetEntityData( name: name ?? this.name, type: type ?? this.type, @@ -8044,6 +8100,7 @@ class TrashedLocalAssetEntityData extends DataClass isFavorite: isFavorite ?? this.isFavorite, orientation: orientation ?? this.orientation, source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, ); TrashedLocalAssetEntityData copyWithCompanion( TrashedLocalAssetEntityCompanion data, @@ -8068,6 +8125,9 @@ class TrashedLocalAssetEntityData extends DataClass ? data.orientation.value : this.orientation, source: data.source.present ? data.source.value : this.source, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, ); } @@ -8086,7 +8146,8 @@ class TrashedLocalAssetEntityData extends DataClass ..write('checksum: $checksum, ') ..write('isFavorite: $isFavorite, ') ..write('orientation: $orientation, ') - ..write('source: $source') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } @@ -8106,6 +8167,7 @@ class TrashedLocalAssetEntityData extends DataClass isFavorite, orientation, source, + playbackStyle, ); @override bool operator ==(Object other) => @@ -8123,7 +8185,8 @@ class TrashedLocalAssetEntityData extends DataClass other.checksum == this.checksum && other.isFavorite == this.isFavorite && other.orientation == this.orientation && - other.source == this.source); + other.source == this.source && + other.playbackStyle == this.playbackStyle); } class TrashedLocalAssetEntityCompanion @@ -8141,6 +8204,7 @@ class TrashedLocalAssetEntityCompanion final Value isFavorite; final Value orientation; final Value source; + final Value playbackStyle; const TrashedLocalAssetEntityCompanion({ this.name = const Value.absent(), this.type = const Value.absent(), @@ -8155,6 +8219,7 @@ class TrashedLocalAssetEntityCompanion this.isFavorite = const Value.absent(), this.orientation = const Value.absent(), this.source = const Value.absent(), + this.playbackStyle = const Value.absent(), }); TrashedLocalAssetEntityCompanion.insert({ required String name, @@ -8170,6 +8235,7 @@ class TrashedLocalAssetEntityCompanion this.isFavorite = const Value.absent(), this.orientation = const Value.absent(), required int source, + this.playbackStyle = const Value.absent(), }) : name = Value(name), type = Value(type), id = Value(id), @@ -8189,6 +8255,7 @@ class TrashedLocalAssetEntityCompanion Expression? isFavorite, Expression? orientation, Expression? source, + Expression? playbackStyle, }) { return RawValuesInsertable({ if (name != null) 'name': name, @@ -8204,6 +8271,7 @@ class TrashedLocalAssetEntityCompanion if (isFavorite != null) 'is_favorite': isFavorite, if (orientation != null) 'orientation': orientation, if (source != null) 'source': source, + if (playbackStyle != null) 'playback_style': playbackStyle, }); } @@ -8221,6 +8289,7 @@ class TrashedLocalAssetEntityCompanion Value? isFavorite, Value? orientation, Value? source, + Value? playbackStyle, }) { return TrashedLocalAssetEntityCompanion( name: name ?? this.name, @@ -8236,6 +8305,7 @@ class TrashedLocalAssetEntityCompanion isFavorite: isFavorite ?? this.isFavorite, orientation: orientation ?? this.orientation, source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, ); } @@ -8281,6 +8351,9 @@ class TrashedLocalAssetEntityCompanion if (source.present) { map['source'] = Variable(source.value); } + if (playbackStyle.present) { + map['playback_style'] = Variable(playbackStyle.value); + } return map; } @@ -8299,618 +8372,8 @@ class TrashedLocalAssetEntityCompanion ..write('checksum: $checksum, ') ..write('isFavorite: $isFavorite, ') ..write('orientation: $orientation, ') - ..write('source: $source') - ..write(')')) - .toString(); - } -} - -class AssetOcrEntity extends Table - with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - AssetOcrEntity(this.attachedDatabase, [this._alias]); - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - late final GeneratedColumn assetId = GeneratedColumn( - 'asset_id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', - ), - ); - late final GeneratedColumn x1 = GeneratedColumn( - 'x1', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn y1 = GeneratedColumn( - 'y1', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn x2 = GeneratedColumn( - 'x2', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn y2 = GeneratedColumn( - 'y2', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn x3 = GeneratedColumn( - 'x3', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn y3 = GeneratedColumn( - 'y3', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn x4 = GeneratedColumn( - 'x4', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn y4 = GeneratedColumn( - 'y4', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn boxScore = GeneratedColumn( - 'box_score', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn textScore = GeneratedColumn( - 'text_score', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - late final GeneratedColumn recognizedText = GeneratedColumn( - 'text', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - late final GeneratedColumn isVisible = GeneratedColumn( - 'is_visible', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_visible" IN (0, 1))', - ), - defaultValue: const CustomExpression('1'), - ); - @override - List get $columns => [ - id, - assetId, - x1, - y1, - x2, - y2, - x3, - y3, - x4, - y4, - boxScore, - textScore, - recognizedText, - isVisible, - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'asset_ocr_entity'; - @override - Set get $primaryKey => {id}; - @override - AssetOcrEntityData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return AssetOcrEntityData( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - assetId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}asset_id'], - )!, - x1: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}x1'], - )!, - y1: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}y1'], - )!, - x2: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}x2'], - )!, - y2: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}y2'], - )!, - x3: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}x3'], - )!, - y3: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}y3'], - )!, - x4: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}x4'], - )!, - y4: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}y4'], - )!, - boxScore: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}box_score'], - )!, - textScore: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}text_score'], - )!, - recognizedText: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}text'], - )!, - isVisible: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}is_visible'], - )!, - ); - } - - @override - AssetOcrEntity createAlias(String alias) { - return AssetOcrEntity(attachedDatabase, alias); - } - - @override - bool get withoutRowId => true; - @override - bool get isStrict => true; -} - -class AssetOcrEntityData extends DataClass - implements Insertable { - final String id; - final String assetId; - final double x1; - final double y1; - final double x2; - final double y2; - final double x3; - final double y3; - final double x4; - final double y4; - final double boxScore; - final double textScore; - final String recognizedText; - final bool isVisible; - const AssetOcrEntityData({ - required this.id, - required this.assetId, - required this.x1, - required this.y1, - required this.x2, - required this.y2, - required this.x3, - required this.y3, - required this.x4, - required this.y4, - required this.boxScore, - required this.textScore, - required this.recognizedText, - required this.isVisible, - }); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['asset_id'] = Variable(assetId); - map['x1'] = Variable(x1); - map['y1'] = Variable(y1); - map['x2'] = Variable(x2); - map['y2'] = Variable(y2); - map['x3'] = Variable(x3); - map['y3'] = Variable(y3); - map['x4'] = Variable(x4); - map['y4'] = Variable(y4); - map['box_score'] = Variable(boxScore); - map['text_score'] = Variable(textScore); - map['text'] = Variable(recognizedText); - map['is_visible'] = Variable(isVisible); - return map; - } - - factory AssetOcrEntityData.fromJson( - Map json, { - ValueSerializer? serializer, - }) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return AssetOcrEntityData( - id: serializer.fromJson(json['id']), - assetId: serializer.fromJson(json['assetId']), - x1: serializer.fromJson(json['x1']), - y1: serializer.fromJson(json['y1']), - x2: serializer.fromJson(json['x2']), - y2: serializer.fromJson(json['y2']), - x3: serializer.fromJson(json['x3']), - y3: serializer.fromJson(json['y3']), - x4: serializer.fromJson(json['x4']), - y4: serializer.fromJson(json['y4']), - boxScore: serializer.fromJson(json['boxScore']), - textScore: serializer.fromJson(json['textScore']), - recognizedText: serializer.fromJson(json['recognizedText']), - isVisible: serializer.fromJson(json['isVisible']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'assetId': serializer.toJson(assetId), - 'x1': serializer.toJson(x1), - 'y1': serializer.toJson(y1), - 'x2': serializer.toJson(x2), - 'y2': serializer.toJson(y2), - 'x3': serializer.toJson(x3), - 'y3': serializer.toJson(y3), - 'x4': serializer.toJson(x4), - 'y4': serializer.toJson(y4), - 'boxScore': serializer.toJson(boxScore), - 'textScore': serializer.toJson(textScore), - 'recognizedText': serializer.toJson(recognizedText), - 'isVisible': serializer.toJson(isVisible), - }; - } - - AssetOcrEntityData copyWith({ - String? id, - String? assetId, - double? x1, - double? y1, - double? x2, - double? y2, - double? x3, - double? y3, - double? x4, - double? y4, - double? boxScore, - double? textScore, - String? recognizedText, - bool? isVisible, - }) => AssetOcrEntityData( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - x1: x1 ?? this.x1, - y1: y1 ?? this.y1, - x2: x2 ?? this.x2, - y2: y2 ?? this.y2, - x3: x3 ?? this.x3, - y3: y3 ?? this.y3, - x4: x4 ?? this.x4, - y4: y4 ?? this.y4, - boxScore: boxScore ?? this.boxScore, - textScore: textScore ?? this.textScore, - recognizedText: recognizedText ?? this.recognizedText, - isVisible: isVisible ?? this.isVisible, - ); - AssetOcrEntityData copyWithCompanion(AssetOcrEntityCompanion data) { - return AssetOcrEntityData( - id: data.id.present ? data.id.value : this.id, - assetId: data.assetId.present ? data.assetId.value : this.assetId, - x1: data.x1.present ? data.x1.value : this.x1, - y1: data.y1.present ? data.y1.value : this.y1, - x2: data.x2.present ? data.x2.value : this.x2, - y2: data.y2.present ? data.y2.value : this.y2, - x3: data.x3.present ? data.x3.value : this.x3, - y3: data.y3.present ? data.y3.value : this.y3, - x4: data.x4.present ? data.x4.value : this.x4, - y4: data.y4.present ? data.y4.value : this.y4, - boxScore: data.boxScore.present ? data.boxScore.value : this.boxScore, - textScore: data.textScore.present ? data.textScore.value : this.textScore, - recognizedText: data.recognizedText.present - ? data.recognizedText.value - : this.recognizedText, - isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, - ); - } - - @override - String toString() { - return (StringBuffer('AssetOcrEntityData(') - ..write('id: $id, ') - ..write('assetId: $assetId, ') - ..write('x1: $x1, ') - ..write('y1: $y1, ') - ..write('x2: $x2, ') - ..write('y2: $y2, ') - ..write('x3: $x3, ') - ..write('y3: $y3, ') - ..write('x4: $x4, ') - ..write('y4: $y4, ') - ..write('boxScore: $boxScore, ') - ..write('textScore: $textScore, ') - ..write('recognizedText: $recognizedText, ') - ..write('isVisible: $isVisible') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, - assetId, - x1, - y1, - x2, - y2, - x3, - y3, - x4, - y4, - boxScore, - textScore, - recognizedText, - isVisible, - ); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AssetOcrEntityData && - other.id == this.id && - other.assetId == this.assetId && - other.x1 == this.x1 && - other.y1 == this.y1 && - other.x2 == this.x2 && - other.y2 == this.y2 && - other.x3 == this.x3 && - other.y3 == this.y3 && - other.x4 == this.x4 && - other.y4 == this.y4 && - other.boxScore == this.boxScore && - other.textScore == this.textScore && - other.recognizedText == this.recognizedText && - other.isVisible == this.isVisible); -} - -class AssetOcrEntityCompanion extends UpdateCompanion { - final Value id; - final Value assetId; - final Value x1; - final Value y1; - final Value x2; - final Value y2; - final Value x3; - final Value y3; - final Value x4; - final Value y4; - final Value boxScore; - final Value textScore; - final Value recognizedText; - final Value isVisible; - const AssetOcrEntityCompanion({ - this.id = const Value.absent(), - this.assetId = const Value.absent(), - this.x1 = const Value.absent(), - this.y1 = const Value.absent(), - this.x2 = const Value.absent(), - this.y2 = const Value.absent(), - this.x3 = const Value.absent(), - this.y3 = const Value.absent(), - this.x4 = const Value.absent(), - this.y4 = const Value.absent(), - this.boxScore = const Value.absent(), - this.textScore = const Value.absent(), - this.recognizedText = const Value.absent(), - this.isVisible = const Value.absent(), - }); - AssetOcrEntityCompanion.insert({ - required String id, - required String assetId, - required double x1, - required double y1, - required double x2, - required double y2, - required double x3, - required double y3, - required double x4, - required double y4, - required double boxScore, - required double textScore, - required String recognizedText, - this.isVisible = const Value.absent(), - }) : id = Value(id), - assetId = Value(assetId), - x1 = Value(x1), - y1 = Value(y1), - x2 = Value(x2), - y2 = Value(y2), - x3 = Value(x3), - y3 = Value(y3), - x4 = Value(x4), - y4 = Value(y4), - boxScore = Value(boxScore), - textScore = Value(textScore), - recognizedText = Value(recognizedText); - static Insertable custom({ - Expression? id, - Expression? assetId, - Expression? x1, - Expression? y1, - Expression? x2, - Expression? y2, - Expression? x3, - Expression? y3, - Expression? x4, - Expression? y4, - Expression? boxScore, - Expression? textScore, - Expression? recognizedText, - Expression? isVisible, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (assetId != null) 'asset_id': assetId, - if (x1 != null) 'x1': x1, - if (y1 != null) 'y1': y1, - if (x2 != null) 'x2': x2, - if (y2 != null) 'y2': y2, - if (x3 != null) 'x3': x3, - if (y3 != null) 'y3': y3, - if (x4 != null) 'x4': x4, - if (y4 != null) 'y4': y4, - if (boxScore != null) 'box_score': boxScore, - if (textScore != null) 'text_score': textScore, - if (recognizedText != null) 'text': recognizedText, - if (isVisible != null) 'is_visible': isVisible, - }); - } - - AssetOcrEntityCompanion copyWith({ - Value? id, - Value? assetId, - Value? x1, - Value? y1, - Value? x2, - Value? y2, - Value? x3, - Value? y3, - Value? x4, - Value? y4, - Value? boxScore, - Value? textScore, - Value? recognizedText, - Value? isVisible, - }) { - return AssetOcrEntityCompanion( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - x1: x1 ?? this.x1, - y1: y1 ?? this.y1, - x2: x2 ?? this.x2, - y2: y2 ?? this.y2, - x3: x3 ?? this.x3, - y3: y3 ?? this.y3, - x4: x4 ?? this.x4, - y4: y4 ?? this.y4, - boxScore: boxScore ?? this.boxScore, - textScore: textScore ?? this.textScore, - recognizedText: recognizedText ?? this.recognizedText, - isVisible: isVisible ?? this.isVisible, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (assetId.present) { - map['asset_id'] = Variable(assetId.value); - } - if (x1.present) { - map['x1'] = Variable(x1.value); - } - if (y1.present) { - map['y1'] = Variable(y1.value); - } - if (x2.present) { - map['x2'] = Variable(x2.value); - } - if (y2.present) { - map['y2'] = Variable(y2.value); - } - if (x3.present) { - map['x3'] = Variable(x3.value); - } - if (y3.present) { - map['y3'] = Variable(y3.value); - } - if (x4.present) { - map['x4'] = Variable(x4.value); - } - if (y4.present) { - map['y4'] = Variable(y4.value); - } - if (boxScore.present) { - map['box_score'] = Variable(boxScore.value); - } - if (textScore.present) { - map['text_score'] = Variable(textScore.value); - } - if (recognizedText.present) { - map['text'] = Variable(recognizedText.value); - } - if (isVisible.present) { - map['is_visible'] = Variable(isVisible.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('AssetOcrEntityCompanion(') - ..write('id: $id, ') - ..write('assetId: $assetId, ') - ..write('x1: $x1, ') - ..write('y1: $y1, ') - ..write('x2: $x2, ') - ..write('y2: $y2, ') - ..write('x3: $x3, ') - ..write('y3: $y3, ') - ..write('x4: $x4, ') - ..write('y4: $y4, ') - ..write('boxScore: $boxScore, ') - ..write('textScore: $textScore, ') - ..write('recognizedText: $recognizedText, ') - ..write('isVisible: $isVisible') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') ..write(')')) .toString(); } @@ -8991,7 +8454,6 @@ class DatabaseAtV21 extends GeneratedDatabase { late final StoreEntity storeEntity = StoreEntity(this); late final TrashedLocalAssetEntity trashedLocalAssetEntity = TrashedLocalAssetEntity(this); - late final AssetOcrEntity assetOcrEntity = AssetOcrEntity(this); late final Index idxPartnerSharedWithId = Index( 'idx_partner_shared_with_id', 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', @@ -9065,7 +8527,6 @@ class DatabaseAtV21 extends GeneratedDatabase { assetFaceEntity, storeEntity, trashedLocalAssetEntity, - assetOcrEntity, idxPartnerSharedWithId, idxLatLng, idxRemoteAlbumAssetAlbumAsset, diff --git a/mobile/test/drift/main/generated/schema_v22.dart b/mobile/test/drift/main/generated/schema_v22.dart new file mode 100644 index 0000000000..b8f5971686 --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v22.dart @@ -0,0 +1,8845 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class UserEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_entity'; + @override + Set get $primaryKey => {id}; + @override + UserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + ); + } + + @override + UserEntity createAlias(String alias) { + return UserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserEntityData extends DataClass implements Insertable { + final String id; + final String name; + final String email; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + const UserEntityData({ + required this.id, + required this.name, + required this.email, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + return map; + } + + factory UserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + }; + } + + UserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + }) => UserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + UserEntityData copyWithCompanion(UserEntityCompanion data) { + return UserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + ); + } + + @override + String toString() { + return (StringBuffer('UserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor); +} + +class UserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + const UserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }); + UserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + }); + } + + UserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + }) { + return UserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } +} + +class RemoteAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn localDateTime = + GeneratedColumn( + 'local_date_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn thumbHash = GeneratedColumn( + 'thumb_hash', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn livePhotoVideoId = GeneratedColumn( + 'live_photo_video_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stackId = GeneratedColumn( + 'stack_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn libraryId = GeneratedColumn( + 'library_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isEdited = GeneratedColumn( + 'is_edited', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_edited" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + isEdited, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + )!, + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + localDateTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_date_time'], + ), + thumbHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumb_hash'], + ), + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + livePhotoVideoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}live_photo_video_id'], + ), + visibility: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}visibility'], + )!, + stackId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}stack_id'], + ), + libraryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}library_id'], + ), + isEdited: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_edited'], + )!, + ); + } + + @override + RemoteAssetEntity createAlias(String alias) { + return RemoteAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String checksum; + final bool isFavorite; + final String ownerId; + final DateTime? localDateTime; + final String? thumbHash; + final DateTime? deletedAt; + final String? livePhotoVideoId; + final int visibility; + final String? stackId; + final String? libraryId; + final bool isEdited; + const RemoteAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.checksum, + required this.isFavorite, + required this.ownerId, + this.localDateTime, + this.thumbHash, + this.deletedAt, + this.livePhotoVideoId, + required this.visibility, + this.stackId, + this.libraryId, + required this.isEdited, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['checksum'] = Variable(checksum); + map['is_favorite'] = Variable(isFavorite); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || localDateTime != null) { + map['local_date_time'] = Variable(localDateTime); + } + if (!nullToAbsent || thumbHash != null) { + map['thumb_hash'] = Variable(thumbHash); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || livePhotoVideoId != null) { + map['live_photo_video_id'] = Variable(livePhotoVideoId); + } + map['visibility'] = Variable(visibility); + if (!nullToAbsent || stackId != null) { + map['stack_id'] = Variable(stackId); + } + if (!nullToAbsent || libraryId != null) { + map['library_id'] = Variable(libraryId); + } + map['is_edited'] = Variable(isEdited); + return map; + } + + factory RemoteAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ownerId: serializer.fromJson(json['ownerId']), + localDateTime: serializer.fromJson(json['localDateTime']), + thumbHash: serializer.fromJson(json['thumbHash']), + deletedAt: serializer.fromJson(json['deletedAt']), + livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), + visibility: serializer.fromJson(json['visibility']), + stackId: serializer.fromJson(json['stackId']), + libraryId: serializer.fromJson(json['libraryId']), + isEdited: serializer.fromJson(json['isEdited']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'ownerId': serializer.toJson(ownerId), + 'localDateTime': serializer.toJson(localDateTime), + 'thumbHash': serializer.toJson(thumbHash), + 'deletedAt': serializer.toJson(deletedAt), + 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), + 'visibility': serializer.toJson(visibility), + 'stackId': serializer.toJson(stackId), + 'libraryId': serializer.toJson(libraryId), + 'isEdited': serializer.toJson(isEdited), + }; + } + + RemoteAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? checksum, + bool? isFavorite, + String? ownerId, + Value localDateTime = const Value.absent(), + Value thumbHash = const Value.absent(), + Value deletedAt = const Value.absent(), + Value livePhotoVideoId = const Value.absent(), + int? visibility, + Value stackId = const Value.absent(), + Value libraryId = const Value.absent(), + bool? isEdited, + }) => RemoteAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime.present + ? localDateTime.value + : this.localDateTime, + thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + livePhotoVideoId: livePhotoVideoId.present + ? livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, + libraryId: libraryId.present ? libraryId.value : this.libraryId, + isEdited: isEdited ?? this.isEdited, + ); + RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { + return RemoteAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + localDateTime: data.localDateTime.present + ? data.localDateTime.value + : this.localDateTime, + thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + livePhotoVideoId: data.livePhotoVideoId.present + ? data.livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: data.visibility.present + ? data.visibility.value + : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, + libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId, + isEdited: data.isEdited.present ? data.isEdited.value : this.isEdited, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId, ') + ..write('isEdited: $isEdited') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + isEdited, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.ownerId == this.ownerId && + other.localDateTime == this.localDateTime && + other.thumbHash == this.thumbHash && + other.deletedAt == this.deletedAt && + other.livePhotoVideoId == this.livePhotoVideoId && + other.visibility == this.visibility && + other.stackId == this.stackId && + other.libraryId == this.libraryId && + other.isEdited == this.isEdited); +} + +class RemoteAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value ownerId; + final Value localDateTime; + final Value thumbHash; + final Value deletedAt; + final Value livePhotoVideoId; + final Value visibility; + final Value stackId; + final Value libraryId; + final Value isEdited; + const RemoteAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.ownerId = const Value.absent(), + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + this.visibility = const Value.absent(), + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + this.isEdited = const Value.absent(), + }); + RemoteAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String checksum, + this.isFavorite = const Value.absent(), + required String ownerId, + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + required int visibility, + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + this.isEdited = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + checksum = Value(checksum), + ownerId = Value(ownerId), + visibility = Value(visibility); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? ownerId, + Expression? localDateTime, + Expression? thumbHash, + Expression? deletedAt, + Expression? livePhotoVideoId, + Expression? visibility, + Expression? stackId, + Expression? libraryId, + Expression? isEdited, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (ownerId != null) 'owner_id': ownerId, + if (localDateTime != null) 'local_date_time': localDateTime, + if (thumbHash != null) 'thumb_hash': thumbHash, + if (deletedAt != null) 'deleted_at': deletedAt, + if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, + if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, + if (libraryId != null) 'library_id': libraryId, + if (isEdited != null) 'is_edited': isEdited, + }); + } + + RemoteAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? ownerId, + Value? localDateTime, + Value? thumbHash, + Value? deletedAt, + Value? livePhotoVideoId, + Value? visibility, + Value? stackId, + Value? libraryId, + Value? isEdited, + }) { + return RemoteAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime ?? this.localDateTime, + thumbHash: thumbHash ?? this.thumbHash, + deletedAt: deletedAt ?? this.deletedAt, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, + libraryId: libraryId ?? this.libraryId, + isEdited: isEdited ?? this.isEdited, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (localDateTime.present) { + map['local_date_time'] = Variable(localDateTime.value); + } + if (thumbHash.present) { + map['thumb_hash'] = Variable(thumbHash.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (livePhotoVideoId.present) { + map['live_photo_video_id'] = Variable(livePhotoVideoId.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + if (stackId.present) { + map['stack_id'] = Variable(stackId.value); + } + if (libraryId.present) { + map['library_id'] = Variable(libraryId.value); + } + if (isEdited.present) { + map['is_edited'] = Variable(isEdited.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId, ') + ..write('isEdited: $isEdited') + ..write(')')) + .toString(); + } +} + +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + primaryAssetId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}primary_asset_id'], + )!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId, + }) => StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId, + }) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + +class LocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn iCloudId = GeneratedColumn( + 'i_cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn playbackStyle = GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + playbackStyle, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + iCloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}i_cloud_id'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + playbackStyle: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, + ); + } + + @override + LocalAssetEntity createAlias(String alias) { + return LocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + final int orientation; + final String? iCloudId; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + final int playbackStyle; + const LocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite, + required this.orientation, + this.iCloudId, + this.adjustmentTime, + this.latitude, + this.longitude, + required this.playbackStyle, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + if (!nullToAbsent || iCloudId != null) { + map['i_cloud_id'] = Variable(iCloudId); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + map['playback_style'] = Variable(playbackStyle); + return map; + } + + factory LocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + iCloudId: serializer.fromJson(json['iCloudId']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + playbackStyle: serializer.fromJson(json['playbackStyle']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + 'iCloudId': serializer.toJson(iCloudId), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'playbackStyle': serializer.toJson(playbackStyle), + }; + } + + LocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + Value iCloudId = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + int? playbackStyle, + }) => LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + playbackStyle, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation && + other.iCloudId == this.iCloudId && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.playbackStyle == this.playbackStyle); +} + +class LocalAssetEntityCompanion extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value orientation; + final Value iCloudId; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + final Value playbackStyle; + const LocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.playbackStyle = const Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.playbackStyle = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + Expression? iCloudId, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + Expression? playbackStyle, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + if (iCloudId != null) 'i_cloud_id': iCloudId, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (playbackStyle != null) 'playback_style': playbackStyle, + }); + } + + LocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation, + Value? iCloudId, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + Value? playbackStyle, + }) { + return LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + iCloudId: iCloudId ?? this.iCloudId, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (iCloudId.present) { + map['i_cloud_id'] = Variable(iCloudId.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (playbackStyle.present) { + map['playback_style'] = Variable(playbackStyle.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const CustomExpression('\'\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn thumbnailAssetId = GeneratedColumn( + 'thumbnail_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn isActivityEnabled = GeneratedColumn( + 'is_activity_enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn order = GeneratedColumn( + 'order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + thumbnailAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumbnail_asset_id'], + ), + isActivityEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_activity_enabled'], + )!, + order: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order'], + )!, + ); + } + + @override + RemoteAlbumEntity createAlias(String alias) { + return RemoteAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final int order; + const RemoteAlbumEntityData({ + required this.id, + required this.name, + required this.description, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['description'] = Variable(description); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || thumbnailAssetId != null) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId); + } + map['is_activity_enabled'] = Variable(isActivityEnabled); + map['order'] = Variable(order); + return map; + } + + factory RemoteAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + thumbnailAssetId: serializer.fromJson(json['thumbnailAssetId']), + isActivityEnabled: serializer.fromJson(json['isActivityEnabled']), + order: serializer.fromJson(json['order']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'thumbnailAssetId': serializer.toJson(thumbnailAssetId), + 'isActivityEnabled': serializer.toJson(isActivityEnabled), + 'order': serializer.toJson(order), + }; + } + + RemoteAlbumEntityData copyWith({ + String? id, + String? name, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + Value thumbnailAssetId = const Value.absent(), + bool? isActivityEnabled, + int? order, + }) => RemoteAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId.present + ? thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + RemoteAlbumEntityData copyWithCompanion(RemoteAlbumEntityCompanion data) { + return RemoteAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + description: data.description.present + ? data.description.value + : this.description, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + thumbnailAssetId: data.thumbnailAssetId.present + ? data.thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: data.isActivityEnabled.present + ? data.isActivityEnabled.value + : this.isActivityEnabled, + order: data.order.present ? data.order.value : this.order, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.description == this.description && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.thumbnailAssetId == this.thumbnailAssetId && + other.isActivityEnabled == this.isActivityEnabled && + other.order == this.order); +} + +class RemoteAlbumEntityCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value description; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value thumbnailAssetId; + final Value isActivityEnabled; + final Value order; + const RemoteAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + this.order = const Value.absent(), + }); + RemoteAlbumEntityCompanion.insert({ + required String id, + required String name, + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + required int order, + }) : id = Value(id), + name = Value(name), + ownerId = Value(ownerId), + order = Value(order); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? description, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? thumbnailAssetId, + Expression? isActivityEnabled, + Expression? order, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId, + if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled, + if (order != null) 'order': order, + }); + } + + RemoteAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? description, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? thumbnailAssetId, + Value? isActivityEnabled, + Value? order, + }) { + return RemoteAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (thumbnailAssetId.present) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId.value); + } + if (isActivityEnabled.present) { + map['is_activity_enabled'] = Variable(isActivityEnabled.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } +} + +class LocalAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn backupSelection = GeneratedColumn( + 'backup_selection', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn isIosSharedAlbum = GeneratedColumn( + 'is_ios_shared_album', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn linkedRemoteAlbumId = + GeneratedColumn( + 'linked_remote_album_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [ + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + backupSelection: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}backup_selection'], + )!, + isIosSharedAlbum: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_ios_shared_album'], + )!, + linkedRemoteAlbumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}linked_remote_album_id'], + ), + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumEntity createAlias(String alias) { + return LocalAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final DateTime updatedAt; + final int backupSelection; + final bool isIosSharedAlbum; + final String? linkedRemoteAlbumId; + final bool? marker_; + const LocalAlbumEntityData({ + required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + required this.isIosSharedAlbum, + this.linkedRemoteAlbumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['updated_at'] = Variable(updatedAt); + map['backup_selection'] = Variable(backupSelection); + map['is_ios_shared_album'] = Variable(isIosSharedAlbum); + if (!nullToAbsent || linkedRemoteAlbumId != null) { + map['linked_remote_album_id'] = Variable(linkedRemoteAlbumId); + } + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: serializer.fromJson(json['backupSelection']), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), + linkedRemoteAlbumId: serializer.fromJson( + json['linkedRemoteAlbumId'], + ), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(backupSelection), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), + 'linkedRemoteAlbumId': serializer.toJson(linkedRemoteAlbumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumEntityData copyWith({ + String? id, + String? name, + DateTime? updatedAt, + int? backupSelection, + bool? isIosSharedAlbum, + Value linkedRemoteAlbumId = const Value.absent(), + Value marker_ = const Value.absent(), + }) => LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId.present + ? linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumEntityData copyWithCompanion(LocalAlbumEntityCompanion data) { + return LocalAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, + linkedRemoteAlbumId: data.linkedRemoteAlbumId.present + ? data.linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && + other.linkedRemoteAlbumId == this.linkedRemoteAlbumId && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value updatedAt; + final Value backupSelection; + final Value isIosSharedAlbum; + final Value linkedRemoteAlbumId; + final Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.updatedAt = const Value.absent(), + this.backupSelection = const Value.absent(), + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumEntityCompanion.insert({ + required String id, + required String name, + this.updatedAt = const Value.absent(), + required int backupSelection, + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }) : id = Value(id), + name = Value(name), + backupSelection = Value(backupSelection); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? updatedAt, + Expression? backupSelection, + Expression? isIosSharedAlbum, + Expression? linkedRemoteAlbumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, + if (linkedRemoteAlbumId != null) + 'linked_remote_album_id': linkedRemoteAlbumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? updatedAt, + Value? backupSelection, + Value? isIosSharedAlbum, + Value? linkedRemoteAlbumId, + Value? marker_, + }) { + return LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = Variable(backupSelection.value); + } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = Variable(isIosSharedAlbum.value); + } + if (linkedRemoteAlbumId.present) { + map['linked_remote_album_id'] = Variable( + linkedRemoteAlbumId.value, + ); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class LocalAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [assetId, albumId, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + LocalAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumAssetEntity createAlias(String alias) { + return LocalAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + final bool? marker_; + const LocalAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumAssetEntityData copyWith({ + String? assetId, + String? albumId, + Value marker_ = const Value.absent(), + }) => LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumAssetEntityData copyWithCompanion( + LocalAlbumAssetEntityCompanion data, + ) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId && + other.marker_ == this.marker_); +} + +class LocalAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + final Value marker_; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + this.marker_ = const Value.absent(), + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + Value? marker_, + }) { + return LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class AuthUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AuthUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isAdmin = GeneratedColumn( + 'is_admin', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_admin" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn quotaSizeInBytes = GeneratedColumn( + 'quota_size_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn quotaUsageInBytes = GeneratedColumn( + 'quota_usage_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn pinCode = GeneratedColumn( + 'pin_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'auth_user_entity'; + @override + Set get $primaryKey => {id}; + @override + AuthUserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AuthUserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + isAdmin: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_admin'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + quotaSizeInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_size_in_bytes'], + )!, + quotaUsageInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_usage_in_bytes'], + )!, + pinCode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pin_code'], + ), + ); + } + + @override + AuthUserEntity createAlias(String alias) { + return AuthUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AuthUserEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String email; + final bool isAdmin; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + final int quotaSizeInBytes; + final int quotaUsageInBytes; + final String? pinCode; + const AuthUserEntityData({ + required this.id, + required this.name, + required this.email, + required this.isAdmin, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + required this.quotaSizeInBytes, + required this.quotaUsageInBytes, + this.pinCode, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['is_admin'] = Variable(isAdmin); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes); + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes); + if (!nullToAbsent || pinCode != null) { + map['pin_code'] = Variable(pinCode); + } + return map; + } + + factory AuthUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AuthUserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + isAdmin: serializer.fromJson(json['isAdmin']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + quotaSizeInBytes: serializer.fromJson(json['quotaSizeInBytes']), + quotaUsageInBytes: serializer.fromJson(json['quotaUsageInBytes']), + pinCode: serializer.fromJson(json['pinCode']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'isAdmin': serializer.toJson(isAdmin), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + 'quotaSizeInBytes': serializer.toJson(quotaSizeInBytes), + 'quotaUsageInBytes': serializer.toJson(quotaUsageInBytes), + 'pinCode': serializer.toJson(pinCode), + }; + } + + AuthUserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? isAdmin, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + int? quotaSizeInBytes, + int? quotaUsageInBytes, + Value pinCode = const Value.absent(), + }) => AuthUserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode.present ? pinCode.value : this.pinCode, + ); + AuthUserEntityData copyWithCompanion(AuthUserEntityCompanion data) { + return AuthUserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + isAdmin: data.isAdmin.present ? data.isAdmin.value : this.isAdmin, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + quotaSizeInBytes: data.quotaSizeInBytes.present + ? data.quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: data.quotaUsageInBytes.present + ? data.quotaUsageInBytes.value + : this.quotaUsageInBytes, + pinCode: data.pinCode.present ? data.pinCode.value : this.pinCode, + ); + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AuthUserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.isAdmin == this.isAdmin && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor && + other.quotaSizeInBytes == this.quotaSizeInBytes && + other.quotaUsageInBytes == this.quotaUsageInBytes && + other.pinCode == this.pinCode); +} + +class AuthUserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value isAdmin; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + final Value quotaSizeInBytes; + final Value quotaUsageInBytes; + final Value pinCode; + const AuthUserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }); + AuthUserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + required int avatarColor, + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email), + avatarColor = Value(avatarColor); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? isAdmin, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + Expression? quotaSizeInBytes, + Expression? quotaUsageInBytes, + Expression? pinCode, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (isAdmin != null) 'is_admin': isAdmin, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + if (quotaSizeInBytes != null) 'quota_size_in_bytes': quotaSizeInBytes, + if (quotaUsageInBytes != null) 'quota_usage_in_bytes': quotaUsageInBytes, + if (pinCode != null) 'pin_code': pinCode, + }); + } + + AuthUserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? isAdmin, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + Value? quotaSizeInBytes, + Value? quotaUsageInBytes, + Value? pinCode, + }) { + return AuthUserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode ?? this.pinCode, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (isAdmin.present) { + map['is_admin'] = Variable(isAdmin.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + if (quotaSizeInBytes.present) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes.value); + } + if (quotaUsageInBytes.present) { + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes.value); + } + if (pinCode.present) { + map['pin_code'] = Variable(pinCode.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } +} + +class UserMetadataEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserMetadataEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + ); + @override + List get $columns => [userId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_metadata_entity'; + @override + Set get $primaryKey => {userId, key}; + @override + UserMetadataEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserMetadataEntityData( + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + key: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + UserMetadataEntity createAlias(String alias) { + return UserMetadataEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserMetadataEntityData extends DataClass + implements Insertable { + final String userId; + final int key; + final Uint8List value; + const UserMetadataEntityData({ + required this.userId, + required this.key, + required this.value, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['user_id'] = Variable(userId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory UserMetadataEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserMetadataEntityData( + userId: serializer.fromJson(json['userId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + UserMetadataEntityData copyWith({ + String? userId, + int? key, + Uint8List? value, + }) => UserMetadataEntityData( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + UserMetadataEntityData copyWithCompanion(UserMetadataEntityCompanion data) { + return UserMetadataEntityData( + userId: data.userId.present ? data.userId.value : this.userId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityData(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(userId, key, $driftBlobEquality.hash(value)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserMetadataEntityData && + other.userId == this.userId && + other.key == this.key && + $driftBlobEquality.equals(other.value, this.value)); +} + +class UserMetadataEntityCompanion + extends UpdateCompanion { + final Value userId; + final Value key; + final Value value; + const UserMetadataEntityCompanion({ + this.userId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + }); + UserMetadataEntityCompanion.insert({ + required String userId, + required int key, + required Uint8List value, + }) : userId = Value(userId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? userId, + Expression? key, + Expression? value, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + UserMetadataEntityCompanion copyWith({ + Value? userId, + Value? key, + Value? value, + }) { + return UserMetadataEntityCompanion( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityCompanion(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} + +class PartnerEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PartnerEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn sharedById = GeneratedColumn( + 'shared_by_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn sharedWithId = GeneratedColumn( + 'shared_with_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn inTimeline = GeneratedColumn( + 'in_timeline', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("in_timeline" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [sharedById, sharedWithId, inTimeline]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'partner_entity'; + @override + Set get $primaryKey => {sharedById, sharedWithId}; + @override + PartnerEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PartnerEntityData( + sharedById: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_by_id'], + )!, + sharedWithId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_with_id'], + )!, + inTimeline: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}in_timeline'], + )!, + ); + } + + @override + PartnerEntity createAlias(String alias) { + return PartnerEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PartnerEntityData extends DataClass + implements Insertable { + final String sharedById; + final String sharedWithId; + final bool inTimeline; + const PartnerEntityData({ + required this.sharedById, + required this.sharedWithId, + required this.inTimeline, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['shared_by_id'] = Variable(sharedById); + map['shared_with_id'] = Variable(sharedWithId); + map['in_timeline'] = Variable(inTimeline); + return map; + } + + factory PartnerEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PartnerEntityData( + sharedById: serializer.fromJson(json['sharedById']), + sharedWithId: serializer.fromJson(json['sharedWithId']), + inTimeline: serializer.fromJson(json['inTimeline']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sharedById': serializer.toJson(sharedById), + 'sharedWithId': serializer.toJson(sharedWithId), + 'inTimeline': serializer.toJson(inTimeline), + }; + } + + PartnerEntityData copyWith({ + String? sharedById, + String? sharedWithId, + bool? inTimeline, + }) => PartnerEntityData( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + PartnerEntityData copyWithCompanion(PartnerEntityCompanion data) { + return PartnerEntityData( + sharedById: data.sharedById.present + ? data.sharedById.value + : this.sharedById, + sharedWithId: data.sharedWithId.present + ? data.sharedWithId.value + : this.sharedWithId, + inTimeline: data.inTimeline.present + ? data.inTimeline.value + : this.inTimeline, + ); + } + + @override + String toString() { + return (StringBuffer('PartnerEntityData(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(sharedById, sharedWithId, inTimeline); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartnerEntityData && + other.sharedById == this.sharedById && + other.sharedWithId == this.sharedWithId && + other.inTimeline == this.inTimeline); +} + +class PartnerEntityCompanion extends UpdateCompanion { + final Value sharedById; + final Value sharedWithId; + final Value inTimeline; + const PartnerEntityCompanion({ + this.sharedById = const Value.absent(), + this.sharedWithId = const Value.absent(), + this.inTimeline = const Value.absent(), + }); + PartnerEntityCompanion.insert({ + required String sharedById, + required String sharedWithId, + this.inTimeline = const Value.absent(), + }) : sharedById = Value(sharedById), + sharedWithId = Value(sharedWithId); + static Insertable custom({ + Expression? sharedById, + Expression? sharedWithId, + Expression? inTimeline, + }) { + return RawValuesInsertable({ + if (sharedById != null) 'shared_by_id': sharedById, + if (sharedWithId != null) 'shared_with_id': sharedWithId, + if (inTimeline != null) 'in_timeline': inTimeline, + }); + } + + PartnerEntityCompanion copyWith({ + Value? sharedById, + Value? sharedWithId, + Value? inTimeline, + }) { + return PartnerEntityCompanion( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sharedById.present) { + map['shared_by_id'] = Variable(sharedById.value); + } + if (sharedWithId.present) { + map['shared_with_id'] = Variable(sharedWithId.value); + } + if (inTimeline.present) { + map['in_timeline'] = Variable(inTimeline.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PartnerEntityCompanion(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } +} + +class RemoteExifEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteExifEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn city = GeneratedColumn( + 'city', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn state = GeneratedColumn( + 'state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn country = GeneratedColumn( + 'country', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn dateTimeOriginal = + GeneratedColumn( + 'date_time_original', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn exposureTime = GeneratedColumn( + 'exposure_time', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn fNumber = GeneratedColumn( + 'f_number', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn focalLength = GeneratedColumn( + 'focal_length', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn iso = GeneratedColumn( + 'iso', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn make = GeneratedColumn( + 'make', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn model = GeneratedColumn( + 'model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn lens = GeneratedColumn( + 'lens', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn projectionType = GeneratedColumn( + 'projection_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_exif_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteExifEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteExifEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + city: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}city'], + ), + state: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state'], + ), + country: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}country'], + ), + dateTimeOriginal: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}date_time_original'], + ), + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + exposureTime: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exposure_time'], + ), + fNumber: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}f_number'], + ), + fileSize: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}file_size'], + ), + focalLength: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}focal_length'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + iso: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}iso'], + ), + make: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}make'], + ), + model: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}model'], + ), + lens: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}lens'], + ), + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}orientation'], + ), + timeZone: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}time_zone'], + ), + rating: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}rating'], + ), + projectionType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_type'], + ), + ); + } + + @override + RemoteExifEntity createAlias(String alias) { + return RemoteExifEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteExifEntityData extends DataClass + implements Insertable { + final String assetId; + final String? city; + final String? state; + final String? country; + final DateTime? dateTimeOriginal; + final String? description; + final int? height; + final int? width; + final String? exposureTime; + final double? fNumber; + final int? fileSize; + final double? focalLength; + final double? latitude; + final double? longitude; + final int? iso; + final String? make; + final String? model; + final String? lens; + final String? orientation; + final String? timeZone; + final int? rating; + final String? projectionType; + const RemoteExifEntityData({ + required this.assetId, + this.city, + this.state, + this.country, + this.dateTimeOriginal, + this.description, + this.height, + this.width, + this.exposureTime, + this.fNumber, + this.fileSize, + this.focalLength, + this.latitude, + this.longitude, + this.iso, + this.make, + this.model, + this.lens, + this.orientation, + this.timeZone, + this.rating, + this.projectionType, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || city != null) { + map['city'] = Variable(city); + } + if (!nullToAbsent || state != null) { + map['state'] = Variable(state); + } + if (!nullToAbsent || country != null) { + map['country'] = Variable(country); + } + if (!nullToAbsent || dateTimeOriginal != null) { + map['date_time_original'] = Variable(dateTimeOriginal); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || exposureTime != null) { + map['exposure_time'] = Variable(exposureTime); + } + if (!nullToAbsent || fNumber != null) { + map['f_number'] = Variable(fNumber); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + if (!nullToAbsent || focalLength != null) { + map['focal_length'] = Variable(focalLength); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + if (!nullToAbsent || iso != null) { + map['iso'] = Variable(iso); + } + if (!nullToAbsent || make != null) { + map['make'] = Variable(make); + } + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || lens != null) { + map['lens'] = Variable(lens); + } + if (!nullToAbsent || orientation != null) { + map['orientation'] = Variable(orientation); + } + if (!nullToAbsent || timeZone != null) { + map['time_zone'] = Variable(timeZone); + } + if (!nullToAbsent || rating != null) { + map['rating'] = Variable(rating); + } + if (!nullToAbsent || projectionType != null) { + map['projection_type'] = Variable(projectionType); + } + return map; + } + + factory RemoteExifEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteExifEntityData( + assetId: serializer.fromJson(json['assetId']), + city: serializer.fromJson(json['city']), + state: serializer.fromJson(json['state']), + country: serializer.fromJson(json['country']), + dateTimeOriginal: serializer.fromJson( + json['dateTimeOriginal'], + ), + description: serializer.fromJson(json['description']), + height: serializer.fromJson(json['height']), + width: serializer.fromJson(json['width']), + exposureTime: serializer.fromJson(json['exposureTime']), + fNumber: serializer.fromJson(json['fNumber']), + fileSize: serializer.fromJson(json['fileSize']), + focalLength: serializer.fromJson(json['focalLength']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + iso: serializer.fromJson(json['iso']), + make: serializer.fromJson(json['make']), + model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), + orientation: serializer.fromJson(json['orientation']), + timeZone: serializer.fromJson(json['timeZone']), + rating: serializer.fromJson(json['rating']), + projectionType: serializer.fromJson(json['projectionType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'city': serializer.toJson(city), + 'state': serializer.toJson(state), + 'country': serializer.toJson(country), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + 'description': serializer.toJson(description), + 'height': serializer.toJson(height), + 'width': serializer.toJson(width), + 'exposureTime': serializer.toJson(exposureTime), + 'fNumber': serializer.toJson(fNumber), + 'fileSize': serializer.toJson(fileSize), + 'focalLength': serializer.toJson(focalLength), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'iso': serializer.toJson(iso), + 'make': serializer.toJson(make), + 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), + 'orientation': serializer.toJson(orientation), + 'timeZone': serializer.toJson(timeZone), + 'rating': serializer.toJson(rating), + 'projectionType': serializer.toJson(projectionType), + }; + } + + RemoteExifEntityData copyWith({ + String? assetId, + Value city = const Value.absent(), + Value state = const Value.absent(), + Value country = const Value.absent(), + Value dateTimeOriginal = const Value.absent(), + Value description = const Value.absent(), + Value height = const Value.absent(), + Value width = const Value.absent(), + Value exposureTime = const Value.absent(), + Value fNumber = const Value.absent(), + Value fileSize = const Value.absent(), + Value focalLength = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value iso = const Value.absent(), + Value make = const Value.absent(), + Value model = const Value.absent(), + Value lens = const Value.absent(), + Value orientation = const Value.absent(), + Value timeZone = const Value.absent(), + Value rating = const Value.absent(), + Value projectionType = const Value.absent(), + }) => RemoteExifEntityData( + assetId: assetId ?? this.assetId, + city: city.present ? city.value : this.city, + state: state.present ? state.value : this.state, + country: country.present ? country.value : this.country, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + description: description.present ? description.value : this.description, + height: height.present ? height.value : this.height, + width: width.present ? width.value : this.width, + exposureTime: exposureTime.present ? exposureTime.value : this.exposureTime, + fNumber: fNumber.present ? fNumber.value : this.fNumber, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + focalLength: focalLength.present ? focalLength.value : this.focalLength, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + iso: iso.present ? iso.value : this.iso, + make: make.present ? make.value : this.make, + model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, + orientation: orientation.present ? orientation.value : this.orientation, + timeZone: timeZone.present ? timeZone.value : this.timeZone, + rating: rating.present ? rating.value : this.rating, + projectionType: projectionType.present + ? projectionType.value + : this.projectionType, + ); + RemoteExifEntityData copyWithCompanion(RemoteExifEntityCompanion data) { + return RemoteExifEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + city: data.city.present ? data.city.value : this.city, + state: data.state.present ? data.state.value : this.state, + country: data.country.present ? data.country.value : this.country, + dateTimeOriginal: data.dateTimeOriginal.present + ? data.dateTimeOriginal.value + : this.dateTimeOriginal, + description: data.description.present + ? data.description.value + : this.description, + height: data.height.present ? data.height.value : this.height, + width: data.width.present ? data.width.value : this.width, + exposureTime: data.exposureTime.present + ? data.exposureTime.value + : this.exposureTime, + fNumber: data.fNumber.present ? data.fNumber.value : this.fNumber, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + focalLength: data.focalLength.present + ? data.focalLength.value + : this.focalLength, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + iso: data.iso.present ? data.iso.value : this.iso, + make: data.make.present ? data.make.value : this.make, + model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, + rating: data.rating.present ? data.rating.value : this.rating, + projectionType: data.projectionType.present + ? data.projectionType.value + : this.projectionType, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityData(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteExifEntityData && + other.assetId == this.assetId && + other.city == this.city && + other.state == this.state && + other.country == this.country && + other.dateTimeOriginal == this.dateTimeOriginal && + other.description == this.description && + other.height == this.height && + other.width == this.width && + other.exposureTime == this.exposureTime && + other.fNumber == this.fNumber && + other.fileSize == this.fileSize && + other.focalLength == this.focalLength && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.iso == this.iso && + other.make == this.make && + other.model == this.model && + other.lens == this.lens && + other.orientation == this.orientation && + other.timeZone == this.timeZone && + other.rating == this.rating && + other.projectionType == this.projectionType); +} + +class RemoteExifEntityCompanion extends UpdateCompanion { + final Value assetId; + final Value city; + final Value state; + final Value country; + final Value dateTimeOriginal; + final Value description; + final Value height; + final Value width; + final Value exposureTime; + final Value fNumber; + final Value fileSize; + final Value focalLength; + final Value latitude; + final Value longitude; + final Value iso; + final Value make; + final Value model; + final Value lens; + final Value orientation; + final Value timeZone; + final Value rating; + final Value projectionType; + const RemoteExifEntityCompanion({ + this.assetId = const Value.absent(), + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }); + RemoteExifEntityCompanion.insert({ + required String assetId, + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? city, + Expression? state, + Expression? country, + Expression? dateTimeOriginal, + Expression? description, + Expression? height, + Expression? width, + Expression? exposureTime, + Expression? fNumber, + Expression? fileSize, + Expression? focalLength, + Expression? latitude, + Expression? longitude, + Expression? iso, + Expression? make, + Expression? model, + Expression? lens, + Expression? orientation, + Expression? timeZone, + Expression? rating, + Expression? projectionType, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (country != null) 'country': country, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + if (description != null) 'description': description, + if (height != null) 'height': height, + if (width != null) 'width': width, + if (exposureTime != null) 'exposure_time': exposureTime, + if (fNumber != null) 'f_number': fNumber, + if (fileSize != null) 'file_size': fileSize, + if (focalLength != null) 'focal_length': focalLength, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (iso != null) 'iso': iso, + if (make != null) 'make': make, + if (model != null) 'model': model, + if (lens != null) 'lens': lens, + if (orientation != null) 'orientation': orientation, + if (timeZone != null) 'time_zone': timeZone, + if (rating != null) 'rating': rating, + if (projectionType != null) 'projection_type': projectionType, + }); + } + + RemoteExifEntityCompanion copyWith({ + Value? assetId, + Value? city, + Value? state, + Value? country, + Value? dateTimeOriginal, + Value? description, + Value? height, + Value? width, + Value? exposureTime, + Value? fNumber, + Value? fileSize, + Value? focalLength, + Value? latitude, + Value? longitude, + Value? iso, + Value? make, + Value? model, + Value? lens, + Value? orientation, + Value? timeZone, + Value? rating, + Value? projectionType, + }) { + return RemoteExifEntityCompanion( + assetId: assetId ?? this.assetId, + city: city ?? this.city, + state: state ?? this.state, + country: country ?? this.country, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + description: description ?? this.description, + height: height ?? this.height, + width: width ?? this.width, + exposureTime: exposureTime ?? this.exposureTime, + fNumber: fNumber ?? this.fNumber, + fileSize: fileSize ?? this.fileSize, + focalLength: focalLength ?? this.focalLength, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + iso: iso ?? this.iso, + make: make ?? this.make, + model: model ?? this.model, + lens: lens ?? this.lens, + orientation: orientation ?? this.orientation, + timeZone: timeZone ?? this.timeZone, + rating: rating ?? this.rating, + projectionType: projectionType ?? this.projectionType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (city.present) { + map['city'] = Variable(city.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (country.present) { + map['country'] = Variable(country.value); + } + if (dateTimeOriginal.present) { + map['date_time_original'] = Variable(dateTimeOriginal.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (exposureTime.present) { + map['exposure_time'] = Variable(exposureTime.value); + } + if (fNumber.present) { + map['f_number'] = Variable(fNumber.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + if (focalLength.present) { + map['focal_length'] = Variable(focalLength.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (iso.present) { + map['iso'] = Variable(iso.value); + } + if (make.present) { + map['make'] = Variable(make.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (lens.present) { + map['lens'] = Variable(lens.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } + if (rating.present) { + map['rating'] = Variable(rating.value); + } + if (projectionType.present) { + map['projection_type'] = Variable(projectionType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + RemoteAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + ); + } + + @override + RemoteAlbumAssetEntity createAlias(String alias) { + return RemoteAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const RemoteAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory RemoteAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + RemoteAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + RemoteAlbumAssetEntityData copyWithCompanion( + RemoteAlbumAssetEntityCompanion data, + ) { + return RemoteAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class RemoteAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const RemoteAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + RemoteAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + RemoteAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + }) { + return RemoteAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn role = GeneratedColumn( + 'role', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [albumId, userId, role]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_user_entity'; + @override + Set get $primaryKey => {albumId, userId}; + @override + RemoteAlbumUserEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumUserEntityData( + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + role: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}role'], + )!, + ); + } + + @override + RemoteAlbumUserEntity createAlias(String alias) { + return RemoteAlbumUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumUserEntityData extends DataClass + implements Insertable { + final String albumId; + final String userId; + final int role; + const RemoteAlbumUserEntityData({ + required this.albumId, + required this.userId, + required this.role, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album_id'] = Variable(albumId); + map['user_id'] = Variable(userId); + map['role'] = Variable(role); + return map; + } + + factory RemoteAlbumUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumUserEntityData( + albumId: serializer.fromJson(json['albumId']), + userId: serializer.fromJson(json['userId']), + role: serializer.fromJson(json['role']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'albumId': serializer.toJson(albumId), + 'userId': serializer.toJson(userId), + 'role': serializer.toJson(role), + }; + } + + RemoteAlbumUserEntityData copyWith({ + String? albumId, + String? userId, + int? role, + }) => RemoteAlbumUserEntityData( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + RemoteAlbumUserEntityData copyWithCompanion( + RemoteAlbumUserEntityCompanion data, + ) { + return RemoteAlbumUserEntityData( + albumId: data.albumId.present ? data.albumId.value : this.albumId, + userId: data.userId.present ? data.userId.value : this.userId, + role: data.role.present ? data.role.value : this.role, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityData(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(albumId, userId, role); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumUserEntityData && + other.albumId == this.albumId && + other.userId == this.userId && + other.role == this.role); +} + +class RemoteAlbumUserEntityCompanion + extends UpdateCompanion { + final Value albumId; + final Value userId; + final Value role; + const RemoteAlbumUserEntityCompanion({ + this.albumId = const Value.absent(), + this.userId = const Value.absent(), + this.role = const Value.absent(), + }); + RemoteAlbumUserEntityCompanion.insert({ + required String albumId, + required String userId, + required int role, + }) : albumId = Value(albumId), + userId = Value(userId), + role = Value(role); + static Insertable custom({ + Expression? albumId, + Expression? userId, + Expression? role, + }) { + return RawValuesInsertable({ + if (albumId != null) 'album_id': albumId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + }); + } + + RemoteAlbumUserEntityCompanion copyWith({ + Value? albumId, + Value? userId, + Value? role, + }) { + return RemoteAlbumUserEntityCompanion( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityCompanion(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } +} + +class RemoteAssetCloudIdEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetCloudIdEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn cloudId = GeneratedColumn( + 'cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_cloud_id_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteAssetCloudIdEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetCloudIdEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + cloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cloud_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + ); + } + + @override + RemoteAssetCloudIdEntity createAlias(String alias) { + return RemoteAssetCloudIdEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetCloudIdEntityData extends DataClass + implements Insertable { + final String assetId; + final String? cloudId; + final DateTime? createdAt; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + const RemoteAssetCloudIdEntityData({ + required this.assetId, + this.cloudId, + this.createdAt, + this.adjustmentTime, + this.latitude, + this.longitude, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || cloudId != null) { + map['cloud_id'] = Variable(cloudId); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + return map; + } + + factory RemoteAssetCloudIdEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetCloudIdEntityData( + assetId: serializer.fromJson(json['assetId']), + cloudId: serializer.fromJson(json['cloudId']), + createdAt: serializer.fromJson(json['createdAt']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'cloudId': serializer.toJson(cloudId), + 'createdAt': serializer.toJson(createdAt), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + }; + } + + RemoteAssetCloudIdEntityData copyWith({ + String? assetId, + Value cloudId = const Value.absent(), + Value createdAt = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + }) => RemoteAssetCloudIdEntityData( + assetId: assetId ?? this.assetId, + cloudId: cloudId.present ? cloudId.value : this.cloudId, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + ); + RemoteAssetCloudIdEntityData copyWithCompanion( + RemoteAssetCloudIdEntityCompanion data, + ) { + return RemoteAssetCloudIdEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityData(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetCloudIdEntityData && + other.assetId == this.assetId && + other.cloudId == this.cloudId && + other.createdAt == this.createdAt && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude); +} + +class RemoteAssetCloudIdEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value cloudId; + final Value createdAt; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + const RemoteAssetCloudIdEntityCompanion({ + this.assetId = const Value.absent(), + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }); + RemoteAssetCloudIdEntityCompanion.insert({ + required String assetId, + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? cloudId, + Expression? createdAt, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (cloudId != null) 'cloud_id': cloudId, + if (createdAt != null) 'created_at': createdAt, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + }); + } + + RemoteAssetCloudIdEntityCompanion copyWith({ + Value? assetId, + Value? cloudId, + Value? createdAt, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + }) { + return RemoteAssetCloudIdEntityCompanion( + assetId: assetId ?? this.assetId, + cloudId: cloudId ?? this.cloudId, + createdAt: createdAt ?? this.createdAt, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (cloudId.present) { + map['cloud_id'] = Variable(cloudId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } +} + +class MemoryEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn data = GeneratedColumn( + 'data', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isSaved = GeneratedColumn( + 'is_saved', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_saved" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn memoryAt = GeneratedColumn( + 'memory_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + late final GeneratedColumn seenAt = GeneratedColumn( + 'seen_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn showAt = GeneratedColumn( + 'show_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn hideAt = GeneratedColumn( + 'hide_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + Set get $primaryKey => {id}; + @override + MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + data: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}data'], + )!, + isSaved: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_saved'], + )!, + memoryAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}memory_at'], + )!, + seenAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}seen_at'], + ), + showAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}show_at'], + ), + hideAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}hide_at'], + ), + ); + } + + @override + MemoryEntity createAlias(String alias) { + return MemoryEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final int type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['owner_id'] = Variable(ownerId); + map['type'] = Variable(type); + map['data'] = Variable(data); + map['is_saved'] = Variable(isSaved); + map['memory_at'] = Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: serializer.fromJson(json['type']), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer.toJson(type), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + MemoryEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + String? ownerId, + int? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + Value seenAt = const Value.absent(), + Value showAt = const Value.absent(), + Value hideAt = const Value.absent(), + }) => MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(MemoryEntityCompanion data) { + return MemoryEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value ownerId; + final Value type; + final Value data; + final Value isSaved; + final Value memoryAt; + final Value seenAt; + final Value showAt; + final Value hideAt; + const MemoryEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.type = const Value.absent(), + this.data = const Value.absent(), + this.isSaved = const Value.absent(), + this.memoryAt = const Value.absent(), + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + required String ownerId, + required int type, + required String data, + this.isSaved = const Value.absent(), + required DateTime memoryAt, + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + type = Value(type), + data = Value(data), + memoryAt = Value(memoryAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? ownerId, + Expression? type, + Expression? data, + Expression? isSaved, + Expression? memoryAt, + Expression? seenAt, + Expression? showAt, + Expression? hideAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + MemoryEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? ownerId, + Value? type, + Value? data, + Value? isSaved, + Value? memoryAt, + Value? seenAt, + Value? showAt, + Value? hideAt, + }) { + return MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} + +class MemoryAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn memoryId = GeneratedColumn( + 'memory_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + Set get $primaryKey => {assetId, memoryId}; + @override + MemoryAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + memoryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}memory_id'], + )!, + ); + } + + @override + MemoryAssetEntity createAlias(String alias) { + return MemoryAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['memory_id'] = Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.memoryId = const Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = Value(assetId), + memoryId = Value(memoryId); + static Insertable custom({ + Expression? assetId, + Expression? memoryId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + MemoryAssetEntityCompanion copyWith({ + Value? assetId, + Value? memoryId, + }) { + return MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} + +class PersonEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PersonEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn faceAssetId = GeneratedColumn( + 'face_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + ); + late final GeneratedColumn isHidden = GeneratedColumn( + 'is_hidden', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_hidden" IN (0, 1))', + ), + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn birthDate = GeneratedColumn( + 'birth_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + Set get $primaryKey => {id}; + @override + PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PersonEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + faceAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}face_asset_id'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + isHidden: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_hidden'], + )!, + color: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color'], + ), + birthDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}birth_date'], + ), + ); + } + + @override + PersonEntity createAlias(String alias) { + return PersonEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['name'] = Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = Variable(faceAssetId); + } + map['is_favorite'] = Variable(isFavorite); + map['is_hidden'] = Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + PersonEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + Value faceAssetId = const Value.absent(), + bool? isFavorite, + bool? isHidden, + Value color = const Value.absent(), + Value birthDate = const Value.absent(), + }) => PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: data.faceAssetId.present + ? data.faceAssetId.value + : this.faceAssetId, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value name; + final Value faceAssetId; + final Value isFavorite; + final Value isHidden; + final Value color; + final Value birthDate; + const PersonEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.name = const Value.absent(), + this.faceAssetId = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isHidden = const Value.absent(), + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const Value.absent(), + required bool isFavorite, + required bool isHidden, + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + name = Value(name), + isFavorite = Value(isFavorite), + isHidden = Value(isHidden); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? name, + Expression? faceAssetId, + Expression? isFavorite, + Expression? isHidden, + Expression? color, + Expression? birthDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + PersonEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? name, + Value? faceAssetId, + Value? isFavorite, + Value? isHidden, + Value? color, + Value? birthDate, + }) { + return PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = Variable(faceAssetId.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = Variable(isHidden.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} + +class AssetFaceEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AssetFaceEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn personId = GeneratedColumn( + 'person_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES person_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn imageWidth = GeneratedColumn( + 'image_width', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn imageHeight = GeneratedColumn( + 'image_height', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX1 = GeneratedColumn( + 'bounding_box_x1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY1 = GeneratedColumn( + 'bounding_box_y1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX2 = GeneratedColumn( + 'bounding_box_x2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY2 = GeneratedColumn( + 'bounding_box_y2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isVisible = GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_face_entity'; + @override + Set get $primaryKey => {id}; + @override + AssetFaceEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AssetFaceEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + personId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}person_id'], + ), + imageWidth: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_width'], + )!, + imageHeight: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_height'], + )!, + boundingBoxX1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x1'], + )!, + boundingBoxY1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y1'], + )!, + boundingBoxX2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x2'], + )!, + boundingBoxY2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y2'], + )!, + sourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_type'], + )!, + isVisible: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_visible'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ); + } + + @override + AssetFaceEntity createAlias(String alias) { + return AssetFaceEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetFaceEntityData extends DataClass + implements Insertable { + final String id; + final String assetId; + final String? personId; + final int imageWidth; + final int imageHeight; + final int boundingBoxX1; + final int boundingBoxY1; + final int boundingBoxX2; + final int boundingBoxY2; + final String sourceType; + final bool isVisible; + final DateTime? deletedAt; + const AssetFaceEntityData({ + required this.id, + required this.assetId, + this.personId, + required this.imageWidth, + required this.imageHeight, + required this.boundingBoxX1, + required this.boundingBoxY1, + required this.boundingBoxX2, + required this.boundingBoxY2, + required this.sourceType, + required this.isVisible, + this.deletedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || personId != null) { + map['person_id'] = Variable(personId); + } + map['image_width'] = Variable(imageWidth); + map['image_height'] = Variable(imageHeight); + map['bounding_box_x1'] = Variable(boundingBoxX1); + map['bounding_box_y1'] = Variable(boundingBoxY1); + map['bounding_box_x2'] = Variable(boundingBoxX2); + map['bounding_box_y2'] = Variable(boundingBoxY2); + map['source_type'] = Variable(sourceType); + map['is_visible'] = Variable(isVisible); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + return map; + } + + factory AssetFaceEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AssetFaceEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + personId: serializer.fromJson(json['personId']), + imageWidth: serializer.fromJson(json['imageWidth']), + imageHeight: serializer.fromJson(json['imageHeight']), + boundingBoxX1: serializer.fromJson(json['boundingBoxX1']), + boundingBoxY1: serializer.fromJson(json['boundingBoxY1']), + boundingBoxX2: serializer.fromJson(json['boundingBoxX2']), + boundingBoxY2: serializer.fromJson(json['boundingBoxY2']), + sourceType: serializer.fromJson(json['sourceType']), + isVisible: serializer.fromJson(json['isVisible']), + deletedAt: serializer.fromJson(json['deletedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'personId': serializer.toJson(personId), + 'imageWidth': serializer.toJson(imageWidth), + 'imageHeight': serializer.toJson(imageHeight), + 'boundingBoxX1': serializer.toJson(boundingBoxX1), + 'boundingBoxY1': serializer.toJson(boundingBoxY1), + 'boundingBoxX2': serializer.toJson(boundingBoxX2), + 'boundingBoxY2': serializer.toJson(boundingBoxY2), + 'sourceType': serializer.toJson(sourceType), + 'isVisible': serializer.toJson(isVisible), + 'deletedAt': serializer.toJson(deletedAt), + }; + } + + AssetFaceEntityData copyWith({ + String? id, + String? assetId, + Value personId = const Value.absent(), + int? imageWidth, + int? imageHeight, + int? boundingBoxX1, + int? boundingBoxY1, + int? boundingBoxX2, + int? boundingBoxY2, + String? sourceType, + bool? isVisible, + Value deletedAt = const Value.absent(), + }) => AssetFaceEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId.present ? personId.value : this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ); + AssetFaceEntityData copyWithCompanion(AssetFaceEntityCompanion data) { + return AssetFaceEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + personId: data.personId.present ? data.personId.value : this.personId, + imageWidth: data.imageWidth.present + ? data.imageWidth.value + : this.imageWidth, + imageHeight: data.imageHeight.present + ? data.imageHeight.value + : this.imageHeight, + boundingBoxX1: data.boundingBoxX1.present + ? data.boundingBoxX1.value + : this.boundingBoxX1, + boundingBoxY1: data.boundingBoxY1.present + ? data.boundingBoxY1.value + : this.boundingBoxY1, + boundingBoxX2: data.boundingBoxX2.present + ? data.boundingBoxX2.value + : this.boundingBoxX2, + boundingBoxY2: data.boundingBoxY2.present + ? data.boundingBoxY2.value + : this.boundingBoxY2, + sourceType: data.sourceType.present + ? data.sourceType.value + : this.sourceType, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ); + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AssetFaceEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.personId == this.personId && + other.imageWidth == this.imageWidth && + other.imageHeight == this.imageHeight && + other.boundingBoxX1 == this.boundingBoxX1 && + other.boundingBoxY1 == this.boundingBoxY1 && + other.boundingBoxX2 == this.boundingBoxX2 && + other.boundingBoxY2 == this.boundingBoxY2 && + other.sourceType == this.sourceType && + other.isVisible == this.isVisible && + other.deletedAt == this.deletedAt); +} + +class AssetFaceEntityCompanion extends UpdateCompanion { + final Value id; + final Value assetId; + final Value personId; + final Value imageWidth; + final Value imageHeight; + final Value boundingBoxX1; + final Value boundingBoxY1; + final Value boundingBoxX2; + final Value boundingBoxY2; + final Value sourceType; + final Value isVisible; + final Value deletedAt; + const AssetFaceEntityCompanion({ + this.id = const Value.absent(), + this.assetId = const Value.absent(), + this.personId = const Value.absent(), + this.imageWidth = const Value.absent(), + this.imageHeight = const Value.absent(), + this.boundingBoxX1 = const Value.absent(), + this.boundingBoxY1 = const Value.absent(), + this.boundingBoxX2 = const Value.absent(), + this.boundingBoxY2 = const Value.absent(), + this.sourceType = const Value.absent(), + this.isVisible = const Value.absent(), + this.deletedAt = const Value.absent(), + }); + AssetFaceEntityCompanion.insert({ + required String id, + required String assetId, + this.personId = const Value.absent(), + required int imageWidth, + required int imageHeight, + required int boundingBoxX1, + required int boundingBoxY1, + required int boundingBoxX2, + required int boundingBoxY2, + required String sourceType, + this.isVisible = const Value.absent(), + this.deletedAt = const Value.absent(), + }) : id = Value(id), + assetId = Value(assetId), + imageWidth = Value(imageWidth), + imageHeight = Value(imageHeight), + boundingBoxX1 = Value(boundingBoxX1), + boundingBoxY1 = Value(boundingBoxY1), + boundingBoxX2 = Value(boundingBoxX2), + boundingBoxY2 = Value(boundingBoxY2), + sourceType = Value(sourceType); + static Insertable custom({ + Expression? id, + Expression? assetId, + Expression? personId, + Expression? imageWidth, + Expression? imageHeight, + Expression? boundingBoxX1, + Expression? boundingBoxY1, + Expression? boundingBoxX2, + Expression? boundingBoxY2, + Expression? sourceType, + Expression? isVisible, + Expression? deletedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (personId != null) 'person_id': personId, + if (imageWidth != null) 'image_width': imageWidth, + if (imageHeight != null) 'image_height': imageHeight, + if (boundingBoxX1 != null) 'bounding_box_x1': boundingBoxX1, + if (boundingBoxY1 != null) 'bounding_box_y1': boundingBoxY1, + if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2, + if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2, + if (sourceType != null) 'source_type': sourceType, + if (isVisible != null) 'is_visible': isVisible, + if (deletedAt != null) 'deleted_at': deletedAt, + }); + } + + AssetFaceEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? personId, + Value? imageWidth, + Value? imageHeight, + Value? boundingBoxX1, + Value? boundingBoxY1, + Value? boundingBoxX2, + Value? boundingBoxY2, + Value? sourceType, + Value? isVisible, + Value? deletedAt, + }) { + return AssetFaceEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId ?? this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt ?? this.deletedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (personId.present) { + map['person_id'] = Variable(personId.value); + } + if (imageWidth.present) { + map['image_width'] = Variable(imageWidth.value); + } + if (imageHeight.present) { + map['image_height'] = Variable(imageHeight.value); + } + if (boundingBoxX1.present) { + map['bounding_box_x1'] = Variable(boundingBoxX1.value); + } + if (boundingBoxY1.present) { + map['bounding_box_y1'] = Variable(boundingBoxY1.value); + } + if (boundingBoxX2.present) { + map['bounding_box_x2'] = Variable(boundingBoxX2.value); + } + if (boundingBoxY2.present) { + map['bounding_box_y2'] = Variable(boundingBoxY2.value); + } + if (sourceType.present) { + map['source_type'] = Variable(sourceType.value); + } + if (isVisible.present) { + map['is_visible'] = Variable(isVisible.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..write(')')) + .toString(); + } +} + +class StoreEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StoreEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stringValue = GeneratedColumn( + 'string_value', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn intValue = GeneratedColumn( + 'int_value', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [id, stringValue, intValue]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'store_entity'; + @override + Set get $primaryKey => {id}; + @override + StoreEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StoreEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + stringValue: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}string_value'], + ), + intValue: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}int_value'], + ), + ); + } + + @override + StoreEntity createAlias(String alias) { + return StoreEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StoreEntityData extends DataClass implements Insertable { + final int id; + final String? stringValue; + final int? intValue; + const StoreEntityData({required this.id, this.stringValue, this.intValue}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || stringValue != null) { + map['string_value'] = Variable(stringValue); + } + if (!nullToAbsent || intValue != null) { + map['int_value'] = Variable(intValue); + } + return map; + } + + factory StoreEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StoreEntityData( + id: serializer.fromJson(json['id']), + stringValue: serializer.fromJson(json['stringValue']), + intValue: serializer.fromJson(json['intValue']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'stringValue': serializer.toJson(stringValue), + 'intValue': serializer.toJson(intValue), + }; + } + + StoreEntityData copyWith({ + int? id, + Value stringValue = const Value.absent(), + Value intValue = const Value.absent(), + }) => StoreEntityData( + id: id ?? this.id, + stringValue: stringValue.present ? stringValue.value : this.stringValue, + intValue: intValue.present ? intValue.value : this.intValue, + ); + StoreEntityData copyWithCompanion(StoreEntityCompanion data) { + return StoreEntityData( + id: data.id.present ? data.id.value : this.id, + stringValue: data.stringValue.present + ? data.stringValue.value + : this.stringValue, + intValue: data.intValue.present ? data.intValue.value : this.intValue, + ); + } + + @override + String toString() { + return (StringBuffer('StoreEntityData(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, stringValue, intValue); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StoreEntityData && + other.id == this.id && + other.stringValue == this.stringValue && + other.intValue == this.intValue); +} + +class StoreEntityCompanion extends UpdateCompanion { + final Value id; + final Value stringValue; + final Value intValue; + const StoreEntityCompanion({ + this.id = const Value.absent(), + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }); + StoreEntityCompanion.insert({ + required int id, + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }) : id = Value(id); + static Insertable custom({ + Expression? id, + Expression? stringValue, + Expression? intValue, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (stringValue != null) 'string_value': stringValue, + if (intValue != null) 'int_value': intValue, + }); + } + + StoreEntityCompanion copyWith({ + Value? id, + Value? stringValue, + Value? intValue, + }) { + return StoreEntityCompanion( + id: id ?? this.id, + stringValue: stringValue ?? this.stringValue, + intValue: intValue ?? this.intValue, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (stringValue.present) { + map['string_value'] = Variable(stringValue.value); + } + if (intValue.present) { + map['int_value'] = Variable(intValue.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StoreEntityCompanion(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } +} + +class TrashedLocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TrashedLocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn playbackStyle = GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + playbackStyle, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'trashed_local_asset_entity'; + @override + Set get $primaryKey => {id, albumId}; + @override + TrashedLocalAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TrashedLocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + playbackStyle: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, + ); + } + + @override + TrashedLocalAssetEntity createAlias(String alias) { + return TrashedLocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class TrashedLocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String albumId; + final String? checksum; + final bool isFavorite; + final int orientation; + final int source; + final int playbackStyle; + const TrashedLocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.albumId, + this.checksum, + required this.isFavorite, + required this.orientation, + required this.source, + required this.playbackStyle, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + map['source'] = Variable(source); + map['playback_style'] = Variable(playbackStyle); + return map; + } + + factory TrashedLocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TrashedLocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + albumId: serializer.fromJson(json['albumId']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + source: serializer.fromJson(json['source']), + playbackStyle: serializer.fromJson(json['playbackStyle']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'albumId': serializer.toJson(albumId), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + 'source': serializer.toJson(source), + 'playbackStyle': serializer.toJson(playbackStyle), + }; + } + + TrashedLocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? albumId, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + int? source, + int? playbackStyle, + }) => TrashedLocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + TrashedLocalAssetEntityData copyWithCompanion( + TrashedLocalAssetEntityCompanion data, + ) { + return TrashedLocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + source: data.source.present ? data.source.value : this.source, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, + ); + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + playbackStyle, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TrashedLocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.albumId == this.albumId && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation && + other.source == this.source && + other.playbackStyle == this.playbackStyle); +} + +class TrashedLocalAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value albumId; + final Value checksum; + final Value isFavorite; + final Value orientation; + final Value source; + final Value playbackStyle; + const TrashedLocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.albumId = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.source = const Value.absent(), + this.playbackStyle = const Value.absent(), + }); + TrashedLocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String albumId, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + required int source, + this.playbackStyle = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + albumId = Value(albumId), + source = Value(source); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? albumId, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + Expression? source, + Expression? playbackStyle, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (albumId != null) 'album_id': albumId, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + if (source != null) 'source': source, + if (playbackStyle != null) 'playback_style': playbackStyle, + }); + } + + TrashedLocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? albumId, + Value? checksum, + Value? isFavorite, + Value? orientation, + Value? source, + Value? playbackStyle, + }) { + return TrashedLocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (playbackStyle.present) { + map['playback_style'] = Variable(playbackStyle.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } +} + +class AssetEditEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AssetEditEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn action = GeneratedColumn( + 'action', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn parameters = GeneratedColumn( + 'parameters', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + ); + late final GeneratedColumn sequence = GeneratedColumn( + 'sequence', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + assetId, + action, + parameters, + sequence, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_edit_entity'; + @override + Set get $primaryKey => {id}; + @override + AssetEditEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AssetEditEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + action: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}action'], + )!, + parameters: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}parameters'], + )!, + sequence: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sequence'], + )!, + ); + } + + @override + AssetEditEntity createAlias(String alias) { + return AssetEditEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetEditEntityData extends DataClass + implements Insertable { + final String id; + final String assetId; + final int action; + final Uint8List parameters; + final int sequence; + const AssetEditEntityData({ + required this.id, + required this.assetId, + required this.action, + required this.parameters, + required this.sequence, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['asset_id'] = Variable(assetId); + map['action'] = Variable(action); + map['parameters'] = Variable(parameters); + map['sequence'] = Variable(sequence); + return map; + } + + factory AssetEditEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AssetEditEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + action: serializer.fromJson(json['action']), + parameters: serializer.fromJson(json['parameters']), + sequence: serializer.fromJson(json['sequence']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'action': serializer.toJson(action), + 'parameters': serializer.toJson(parameters), + 'sequence': serializer.toJson(sequence), + }; + } + + AssetEditEntityData copyWith({ + String? id, + String? assetId, + int? action, + Uint8List? parameters, + int? sequence, + }) => AssetEditEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + AssetEditEntityData copyWithCompanion(AssetEditEntityCompanion data) { + return AssetEditEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + action: data.action.present ? data.action.value : this.action, + parameters: data.parameters.present + ? data.parameters.value + : this.parameters, + sequence: data.sequence.present ? data.sequence.value : this.sequence, + ); + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + action, + $driftBlobEquality.hash(parameters), + sequence, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AssetEditEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.action == this.action && + $driftBlobEquality.equals(other.parameters, this.parameters) && + other.sequence == this.sequence); +} + +class AssetEditEntityCompanion extends UpdateCompanion { + final Value id; + final Value assetId; + final Value action; + final Value parameters; + final Value sequence; + const AssetEditEntityCompanion({ + this.id = const Value.absent(), + this.assetId = const Value.absent(), + this.action = const Value.absent(), + this.parameters = const Value.absent(), + this.sequence = const Value.absent(), + }); + AssetEditEntityCompanion.insert({ + required String id, + required String assetId, + required int action, + required Uint8List parameters, + required int sequence, + }) : id = Value(id), + assetId = Value(assetId), + action = Value(action), + parameters = Value(parameters), + sequence = Value(sequence); + static Insertable custom({ + Expression? id, + Expression? assetId, + Expression? action, + Expression? parameters, + Expression? sequence, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (action != null) 'action': action, + if (parameters != null) 'parameters': parameters, + if (sequence != null) 'sequence': sequence, + }); + } + + AssetEditEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? action, + Value? parameters, + Value? sequence, + }) { + return AssetEditEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (action.present) { + map['action'] = Variable(action.value); + } + if (parameters.present) { + map['parameters'] = Variable(parameters.value); + } + if (sequence.present) { + map['sequence'] = Variable(sequence.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV22 extends GeneratedDatabase { + DatabaseAtV22(QueryExecutor e) : super(e); + late final UserEntity userEntity = UserEntity(this); + late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); + late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final RemoteAlbumEntity remoteAlbumEntity = RemoteAlbumEntity(this); + late final LocalAlbumEntity localAlbumEntity = LocalAlbumEntity(this); + late final LocalAlbumAssetEntity localAlbumAssetEntity = + LocalAlbumAssetEntity(this); + late final Index idxLocalAlbumAssetAlbumAsset = Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAlbumOwnerId = Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + late final Index idxLocalAssetChecksum = Index( + 'idx_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)', + ); + late final Index idxLocalAssetCloudId = Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', + ); + late final Index idxStackPrimaryAssetId = Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', + ); + late final Index idxRemoteAssetOwnerChecksum = Index( + 'idx_remote_asset_owner_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', + ); + late final Index uQRemoteAssetsOwnerChecksum = Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', + ); + late final Index uQRemoteAssetsOwnerLibraryChecksum = Index( + 'UQ_remote_assets_owner_library_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', + ); + late final Index idxRemoteAssetChecksum = Index( + 'idx_remote_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', + ); + late final Index idxRemoteAssetStackId = Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + late final Index idxRemoteAssetLocalDateTimeDay = Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + late final Index idxRemoteAssetLocalDateTimeMonth = Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + late final AuthUserEntity authUserEntity = AuthUserEntity(this); + late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); + late final PartnerEntity partnerEntity = PartnerEntity(this); + late final RemoteExifEntity remoteExifEntity = RemoteExifEntity(this); + late final RemoteAlbumAssetEntity remoteAlbumAssetEntity = + RemoteAlbumAssetEntity(this); + late final RemoteAlbumUserEntity remoteAlbumUserEntity = + RemoteAlbumUserEntity(this); + late final RemoteAssetCloudIdEntity remoteAssetCloudIdEntity = + RemoteAssetCloudIdEntity(this); + late final MemoryEntity memoryEntity = MemoryEntity(this); + late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final PersonEntity personEntity = PersonEntity(this); + late final AssetFaceEntity assetFaceEntity = AssetFaceEntity(this); + late final StoreEntity storeEntity = StoreEntity(this); + late final TrashedLocalAssetEntity trashedLocalAssetEntity = + TrashedLocalAssetEntity(this); + late final AssetEditEntity assetEditEntity = AssetEditEntity(this); + late final Index idxPartnerSharedWithId = Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + late final Index idxLatLng = Index( + 'idx_lat_lng', + 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', + ); + late final Index idxRemoteAlbumAssetAlbumAsset = Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAssetCloudId = Index( + 'idx_remote_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)', + ); + late final Index idxPersonOwnerId = Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + late final Index idxAssetFacePersonId = Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + late final Index idxAssetFaceAssetId = Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', + ); + late final Index idxTrashedLocalAssetChecksum = Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', + ); + late final Index idxTrashedLocalAssetAlbum = Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', + ); + late final Index idxAssetEditAssetId = Index( + 'idx_asset_edit_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + assetEditEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + idxAssetEditAssetId, + ]; + @override + int get schemaVersion => 22; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index f3d6ab42a8..90a7f11737 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -64,6 +64,7 @@ abstract final class LocalAssetStub { type: AssetType.image, createdAt: DateTime(2025), updatedAt: DateTime(2025, 2), + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); @@ -73,6 +74,7 @@ abstract final class LocalAssetStub { type: AssetType.image, createdAt: DateTime(2000), updatedAt: DateTime(20021), + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); } diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 62aae4c0da..85eebacb14 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -54,13 +54,10 @@ void main() { when(() => mockApiService.apiClient).thenReturn(mockApiClient); when(() => mockApiService.syncApi).thenReturn(mockSyncApi); when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api'); - when(() => mockApiService.applyToParams(any(), any())).thenAnswer((_) async => {}); - // Mock HTTP client behavior when(() => mockHttpClient.send(any())).thenAnswer((_) async => mockStreamedResponse); when(() => mockStreamedResponse.statusCode).thenReturn(200); when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream)); - when(() => mockHttpClient.close()).thenAnswer((_) => {}); sut = SyncApiRepository(mockApiService); }); @@ -133,7 +130,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); expect(receivedEventsBatch1.length, testBatchSize); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges does not process remaining lines in finally block if aborted', () async { @@ -181,7 +177,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges processes remaining lines in finally block if not aborted', () async { @@ -240,7 +235,6 @@ void main() { expect(onDataCallCount, 2); expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch2.length, 1); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges handles stream error gracefully', () async { @@ -265,7 +259,6 @@ void main() { await expectLater(streamChangesFuture, throwsA(streamError)); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges throws ApiException on non-200 status code', () async { @@ -293,6 +286,5 @@ void main() { ); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); } diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index 41dc46823d..585ffcb499 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -194,6 +194,7 @@ void main() { latitude: 37.7749, longitude: -122.4194, adjustmentTime: DateTime(2026, 1, 2), + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); @@ -243,6 +244,7 @@ void main() { cloudId: 'cloud-id-123', latitude: 37.7749, longitude: -122.4194, + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); @@ -281,6 +283,7 @@ void main() { createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 2), cloudId: null, // No cloudId + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); @@ -323,6 +326,7 @@ void main() { cloudId: 'cloud-id-livephoto', latitude: 37.7749, longitude: -122.4194, + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 9d94e71052..30d4e2e6d4 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -155,6 +155,7 @@ abstract final class TestUtils { width: width, height: height, orientation: orientation, + playbackStyle: domain.AssetPlaybackStyle.image, isEdited: false, ); } diff --git a/mobile/test/test_utils/medium_factory.dart b/mobile/test/test_utils/medium_factory.dart index b6f39ac3bd..50e73e5b5e 100644 --- a/mobile/test/test_utils/medium_factory.dart +++ b/mobile/test/test_utils/medium_factory.dart @@ -27,6 +27,7 @@ class MediumFactory { type: type ?? AssetType.image, createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); } diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index 01ae50b6c4..b5540f9dc7 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -23,6 +23,7 @@ LocalAsset createLocalAsset({ createdAt: createdAt ?? DateTime.now(), updatedAt: updatedAt ?? DateTime.now(), isFavorite: isFavorite, + playbackStyle: AssetPlaybackStyle.image, isEdited: false, ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bbb6791822..7e101fcc66 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9407,10 +9407,27 @@ "name": "rating", "required": false, "in": "query", - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable", "schema": { "minimum": -1, "maximum": 5, + "nullable": true, "type": "number" } }, @@ -11588,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": { @@ -11660,6 +11661,7 @@ "state": "Stable" } ], + "x-immich-permission": "sharedLink.update", "x-immich-state": "Stable" }, "put": { @@ -13475,6 +13477,16 @@ "type": "string" } }, + { + "name": "bbox", + "required": false, + "in": "query", + "description": "Bounding box coordinates as west,south,east,north (WGS84)", + "schema": { + "example": "11.075683,49.416711,11.117589,49.454875", + "type": "string" + } + }, { "name": "isFavorite", "required": false, @@ -13651,6 +13663,16 @@ "type": "string" } }, + { + "name": "bbox", + "required": false, + "in": "query", + "description": "Bounding box coordinates as west,south,east,north (WGS84)", + "schema": { + "example": "11.075683,49.416711,11.117589,49.454875", + "type": "string" + } + }, { "name": "isFavorite", "required": false, @@ -15872,10 +15894,27 @@ "type": "number" }, "rating": { - "description": "Rating", + "description": "Rating in range [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "timeZone": { "description": "Time zone (IANA timezone)", @@ -17206,6 +17245,7 @@ "mp3", "aac", "libopus", + "opus", "pcm_s16le" ], "type": "string" @@ -18988,10 +19028,27 @@ "type": "string" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "size": { "description": "Number of results to return", @@ -20714,10 +20771,27 @@ "type": "array" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "size": { "description": "Number of results to return", @@ -22088,10 +22162,27 @@ "type": "string" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "size": { "description": "Number of results to return", @@ -22322,10 +22413,27 @@ "type": "array" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "state": { "description": "Filter by state/province name", @@ -25312,10 +25420,27 @@ "type": "number" }, "rating": { - "description": "Rating", + "description": "Rating in range [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "visibility": { "allOf": [ diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 8f057df6cc..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.13", + "@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 adc5af8808..4269d8c1c1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -834,8 +834,8 @@ export type AssetBulkUpdateDto = { latitude?: number; /** Longitude coordinate */ longitude?: number; - /** Rating */ - rating?: number; + /** Rating in range [1-5], or null for unrated */ + rating?: number | null; /** Time zone (IANA timezone) */ timeZone?: string; /** Asset visibility */ @@ -944,8 +944,8 @@ export type UpdateAssetDto = { livePhotoVideoId?: string | null; /** Longitude coordinate */ longitude?: number; - /** Rating */ - rating?: number; + /** Rating in range [1-5], or null for unrated */ + rating?: number | null; /** Asset visibility */ visibility?: AssetVisibility; }; @@ -1711,8 +1711,8 @@ export type MetadataSearchDto = { personIds?: string[]; /** Filter by preview file path */ previewPath?: string; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Number of results to return */ size?: number; /** Filter by state/province name */ @@ -1827,8 +1827,8 @@ export type RandomSearchDto = { ocr?: string; /** Filter by person IDs */ personIds?: string[]; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Number of results to return */ size?: number; /** Filter by state/province name */ @@ -1903,8 +1903,8 @@ export type SmartSearchDto = { query?: string; /** Asset ID to use as search reference */ queryAssetId?: string; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Number of results to return */ size?: number; /** Filter by state/province name */ @@ -1969,8 +1969,8 @@ export type StatisticsSearchDto = { ocr?: string; /** Filter by person IDs */ personIds?: string[]; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Filter by state/province name */ state?: string | null; /** Filter by tag IDs */ @@ -5492,7 +5492,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat model?: string | null; ocr?: string; personIds?: string[]; - rating?: number; + rating?: number | null; size?: number; state?: string | null; tagIds?: string[] | null; @@ -6025,19 +6025,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 @@ -6459,8 +6454,9 @@ export function tagAssets({ id, bulkIdsDto }: { /** * Get time bucket */ -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; + bbox?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; @@ -6480,6 +6476,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers data: TimeBucketAssetResponseDto; }>(`/timeline/bucket${QS.query(QS.explode({ albumId, + bbox, isFavorite, isTrashed, key, @@ -6500,8 +6497,9 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers /** * Get time buckets */ -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; + bbox?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; @@ -6520,6 +6518,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per data: TimeBucketsResponseDto[]; }>(`/timeline/buckets${QS.query(QS.explode({ albumId, + bbox, isFavorite, isTrashed, key, @@ -7361,6 +7360,7 @@ export enum AudioCodec { Mp3 = "mp3", Aac = "aac", Libopus = "libopus", + Opus = "opus", PcmS16Le = "pcm_s16le" } export enum VideoContainer { diff --git a/package.json b/package.json index b49e12c3e9..4449cfbdd2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.5.6", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937", + "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "engines": { "pnpm": ">=10.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7568e0436..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.13 - version: 24.10.13 + specifier: ^24.11.0 + version: 24.12.0 '@vitest/coverage-v8': - specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.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)) + specifier: ^4.0.0 + 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 @@ -106,19 +106,19 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + 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.10.13)(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.10.13)(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.10.13)(happy-dom@20.6.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) + specifier: ^4.0.0 + 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@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.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)) + 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 @@ -148,7 +148,7 @@ importers: version: 3.1.1(@types/react@19.2.14)(react@18.3.1) autoprefixer: specifier: ^10.4.17 - version: 10.4.24(postcss@8.5.6) + version: 10.4.27(postcss@8.5.8) docusaurus-lunr-search: specifier: ^3.3.2 version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -157,7 +157,7 @@ importers: version: 2.3.9 postcss: specifier: ^8.4.25 - version: 8.5.6 + version: 8.5.8 prism-react-renderer: specifier: ^2.3.1 version: 2.4.1(react@18.3.1) @@ -220,11 +220,11 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.13 - version: 24.10.13 + 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.18.0 + version: 8.20.0 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -281,13 +281,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) utimes: specifier: ^5.2.1 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.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.10.13)(happy-dom@20.6.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) + specifier: ^4.0.0 + 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: @@ -320,8 +323,8 @@ importers: version: 1.2.0 devDependencies: '@types/node': - specifier: ^24.10.13 - version: 24.10.13 + specifier: ^24.11.0 + version: 24.12.0 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -344,65 +347,65 @@ importers: specifier: 2.0.0-rc13 version: 2.0.0-rc13 '@immich/sql-tools': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.3.2 + 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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.69.3) + 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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(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.3)(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.39.0 + version: 1.40.0 '@react-email/components': specifier: ^0.5.0 version: 0.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -429,7 +432,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.69.3 + version: 5.70.4 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -437,8 +440,8 @@ importers: specifier: ^0.5.1 version: 0.5.1 class-validator: - specifier: ^0.14.0 - version: 0.14.3 + specifier: ^0.15.0 + version: 0.15.1 compression: specifier: ^1.8.0 version: 1.8.1 @@ -453,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 @@ -474,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 @@ -485,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 @@ -501,19 +504,19 @@ importers: version: 0.40.3 multer: specifier: ^2.0.2 - version: 2.0.2 + 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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.10.13)(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.3)(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.3)(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.3)(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 @@ -522,10 +525,10 @@ importers: version: 6.8.2 pg: specifier: ^8.11.3 - version: 8.18.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 @@ -589,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.11(@swc/helpers@0.5.17))(@types/node@24.10.13) + 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.3)(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.11(@swc/helpers@0.5.17) + version: 1.15.18(@swc/helpers@0.5.17) '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -631,7 +634,7 @@ importers: version: 9.0.10 '@types/lodash': specifier: ^4.14.197 - version: 4.17.23 + version: 4.17.24 '@types/luxon': specifier: ^3.6.2 version: 3.7.1 @@ -640,13 +643,13 @@ importers: version: 4.13.4 '@types/multer': specifier: ^2.0.0 - version: 2.0.0 + version: 2.1.0 '@types/node': - specifier: ^24.10.13 - version: 24.10.13 + specifier: ^24.11.0 + version: 24.12.0 '@types/nodemailer': specifier: ^7.0.0 - version: 7.0.10 + version: 7.0.11 '@types/picomatch': specifier: ^4.0.0 version: 4.0.2 @@ -658,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 @@ -673,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.10.13)(happy-dom@20.6.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)) + 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) @@ -688,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 @@ -721,16 +724,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + 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.11(@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.10.13)(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.10.13)(happy-dom@20.6.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) + 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: @@ -745,7 +748,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0) + 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 @@ -778,7 +781,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.53.0) + version: 0.3.9(svelte@5.53.7) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -796,7 +799,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.6.3 + version: 20.8.3 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -811,7 +814,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.18.0 + version: 5.19.0 pmtiles: specifier: ^4.3.0 version: 4.4.0 @@ -829,19 +832,25 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.53.0) + version: 4.0.1(svelte@5.53.7) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.53.0) + version: 3.11.0(svelte@5.53.7) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.53.0) + version: 1.2.6(svelte@5.53.7) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.53.0) + 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 @@ -866,25 +875,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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: 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.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(vite@7.3.1(@types/node@25.3.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: 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.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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: 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.0)(vite@7.3.1(@types/node@25.3.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.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.0(vite@7.3.1(@types/node@25.3.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: 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.0)(vite@7.3.1(@types/node@25.3.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@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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)) + 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) @@ -907,8 +916,8 @@ importers: specifier: ^1.5.5 version: 1.5.6 '@vitest/coverage-v8': - specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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)) + specifier: ^4.0.0 + 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 @@ -920,10 +929,10 @@ importers: version: 10.1.8(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-compat: specifier: ^6.0.2 - version: 6.2.0(eslint@10.0.2(jiti@2.6.1)) + 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.0) + 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)) @@ -932,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 @@ -944,34 +953,34 @@ 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.0) + 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.0 - version: 5.53.0 + specifier: 5.53.7 + version: 5.53.7 svelte-check: specifier: ^4.1.5 - version: 4.4.1(picomatch@4.0.3)(svelte@5.53.0)(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.4.1(svelte@5.53.0) + version: 1.6.0(svelte@5.53.7) tailwindcss: specifier: ^4.1.7 - version: 4.2.0 + version: 4.2.1 typescript: specifier: ^5.8.3 version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + 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.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@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: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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) + specifier: ^4.0.0 + 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: @@ -1225,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 @@ -1688,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': @@ -3017,8 +3026,9 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} - '@immich/sql-tools@0.2.0': - resolution: {integrity: sha512-AH0GRIUYrckNKuid5uO33vgRbGaznhRtArdQ91K310A1oUFjaoNzOaZyZhXwEmft3WYeC1bx4fdgUeois2QH5A==} + '@immich/sql-tools@0.3.2': + resolution: {integrity: sha512-UWhy/+Lf8C1dJip5wPfFytI3Vq/9UyDKQE1ROjXwVhT6E/CPgBkRLwHPetjYGPJ4o1JVVpRLnEEJCXdvzqVpGw==} + hasBin: true '@immich/svelte-markdown-preprocess@0.2.1': resolution: {integrity: sha512-mbr/g75lO8Zh+ELCuYrZP0XB4gf2UbK8rJcGYMYxFJJzMMunV+sm9FqtV1dbwW2dpXzCZGz1XPCEZ6oo526TbA==} @@ -3179,13 +3189,8 @@ packages: '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -3350,15 +3355,15 @@ packages: '@maplibre/geojson-vt@5.0.4': resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} - '@maplibre/maplibre-gl-style-spec@24.4.1': - resolution: {integrity: sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==} + '@maplibre/maplibre-gl-style-spec@24.6.0': + resolution: {integrity: sha512-+lxMYE+DvInshwVrqSQ3CkW9YRwVlRXeDzfthVOa1c9pwK5d7YgCwhgFwlSmjJLvTXn4gL8EvPUGT620sk2Pzg==} hasBin: true '@maplibre/mlt@1.1.6': resolution: {integrity: sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==} - '@maplibre/vt-pbf@4.2.1': - resolution: {integrity: sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==} + '@maplibre/vt-pbf@4.3.0': + resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==} '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -3449,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' @@ -3462,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 @@ -3493,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 @@ -3534,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 @@ -3547,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 @@ -3591,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 @@ -3689,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' @@ -3753,44 +3758,44 @@ 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' - '@opentelemetry/semantic-conventions@1.39.0': - resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} '@opentelemetry/sql-common@0.41.2': @@ -3920,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==} @@ -4320,23 +4325,23 @@ packages: peerDependencies: '@sveltejs/kit': ^2.0.0 - '@sveltejs/enhanced-img@0.10.2': - resolution: {integrity: sha512-HcIX7KFaLe+3ZD+GcMIlOGKODO8zb8p6I5tY8aoM9tz4GwueGyn9gILyTWZHqXYgg7PXto++ELB/q68wC9j4qw==} + '@sveltejs/enhanced-img@0.10.3': + resolution: {integrity: sha512-/6tYiqVmVgWcntSD/TChENE74yN8Gde9JEN8gyGKtm2ytlsUzGiS8loPPiO7Lh4V/rSsOFbvLdXPdiNVztMArA==} peerDependencies: '@sveltejs/vite-plugin-svelte': ^6.0.0 svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.52.2': - resolution: {integrity: sha512-1in76dftrofUt138rVLvYuwiQLkg9K3cG8agXEE6ksf7gCGs8oIr3+pFrVtbRmY9JvW+psW5fvLM/IwVybOLBA==} + '@sveltejs/kit@2.53.4': + resolution: {integrity: sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.0.0 - '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 svelte: ^4.0.0 || ^5.0.0-next.0 typescript: ^5.3.3 - vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 peerDependenciesMeta: '@opentelemetry/api': optional: true @@ -4436,72 +4441,72 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.15.11': - resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==} + '@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.11': - resolution: {integrity: sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==} + '@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.11': - resolution: {integrity: sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==} + '@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.11': - resolution: {integrity: sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==} + '@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.11': - resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} + '@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.11': - resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} + '@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.11': - resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} + '@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.11': - resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} + '@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.11': - resolution: {integrity: sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==} + '@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.11': - resolution: {integrity: sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==} + '@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.11': - resolution: {integrity: sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==} + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4522,69 +4527,69 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} - '@tailwindcss/node@4.2.0': - resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - '@tailwindcss/oxide-android-arm64@4.2.0': - resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.0': - resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.0': - resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.0': - resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': - resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': - resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': - resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': - resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.0': - resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.0': - resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4595,24 +4600,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': - resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': - resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.0': - resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.2.0': - resolution: {integrity: sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==} + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 @@ -4953,8 +4958,8 @@ packages: '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - '@types/lodash@4.17.23': - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} @@ -4980,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==} @@ -4992,14 +4997,14 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@25.4.0': + resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==} - '@types/nodemailer@7.0.10': - resolution: {integrity: sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==} + '@types/nodemailer@7.0.11': + resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} '@types/oidc-provider@9.5.0': resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} @@ -5013,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==} @@ -5052,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==} @@ -5127,63 +5132,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.56.0': - resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.0 + '@typescript-eslint/parser': ^8.56.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.56.0': - resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.56.0': - resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.56.0': - resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.0': - resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.56.0': - resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.56.0': - resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.56.0': - resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.56.0': - resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.56.0': - resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -5202,9 +5207,21 @@ packages: '@vitest/browser': optional: true + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -5216,21 +5233,47 @@ packages: vite: optional: true + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@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.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@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.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5382,9 +5425,6 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -5481,6 +5521,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -5511,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==} @@ -5533,8 +5577,8 @@ packages: autocomplete.js@0.37.1: resolution: {integrity: sha512-PgSe9fHYhZEsm/9jggbjtVsGXJkPLvd+9mC7gZJ662vVL5CRWEtm/mIrrzCx0MrNxHVwxD5d00UOn6NsmL2LUQ==} - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -5635,8 +5679,9 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} hasBin: true batch-cluster@17.3.1: @@ -5701,8 +5746,8 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.3: - resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -5738,8 +5783,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.69.3: - resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==} + bullmq@5.70.4: + resolution: {integrity: sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -5824,8 +5869,8 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001774: - resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + caniuse-lite@1.0.30001776: + resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} canvas@2.11.2: resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} @@ -5838,6 +5883,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -5868,8 +5917,8 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} cheerio-select@2.1.0: @@ -5927,8 +5976,8 @@ packages: class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} - class-validator@0.14.3: - resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==} + 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==} @@ -6058,6 +6107,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -6824,8 +6877,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-to-chromium@1.5.302: + resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -6867,8 +6920,8 @@ packages: resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} entities@2.2.0: @@ -6982,8 +7035,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-compat@6.2.0: - resolution: {integrity: sha512-Ihz4zAeHKzyksDDUTObrYQxaqnV/pFlAiZoWkMuWM9XGf4O191ReQFYv516zcs9QVJ2vX+MMpqr1yEfTkXVETQ==} + eslint-plugin-compat@6.2.1: + resolution: {integrity: sha512-gLKqUH+lQcCL+HzsROUjBDvakc5Zaga51Y4ZAkPCXc41pzKBfyluqTr2j8zOx8QQQb7zyglu1LVoL5aSNWf2SQ==} engines: {node: '>=18.x'} peerDependencies: eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -7163,17 +7216,17 @@ 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.3.0: @@ -7542,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: @@ -7593,8 +7646,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.6.3: - resolution: {integrity: sha512-QAMY7d228dHs8gb9NG4SJ3OxQo4r+NGN8pOXGZ3SGfQf/XYuuYubrtZ25QVY2WoUQdskhRXSXb4R4mcRk+hV1w==} + happy-dom@20.8.3: + resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -7735,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==} @@ -7858,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==} @@ -7921,8 +7978,8 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.9.2: - resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + ioredis@5.10.0: + resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} engines: {node: '>=12.22.0'} ioredis@5.9.3: @@ -8105,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: @@ -8194,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==} @@ -8339,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'} @@ -8375,8 +8431,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.12.31: - resolution: {integrity: sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==} + libphonenumber-js@1.12.38: + resolution: {integrity: sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==} lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} @@ -8610,6 +8666,9 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -8622,8 +8681,8 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} - maplibre-gl@5.18.0: - resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} + maplibre-gl@5.19.0: + resolution: {integrity: sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -8955,12 +9014,8 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} - engines: {node: 20 || >=22} - - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} minimatch@3.1.2: @@ -9024,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'} @@ -9075,8 +9126,8 @@ packages: msgpackr@1.11.5: resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - multer@2.0.2: - resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} multicast-dns@7.2.5: @@ -9539,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.11.0: - resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: pg: '>=8.0' - pg-protocol@1.11.0: - resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + 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.18.0: - resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -10087,8 +10138,8 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -10138,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 @@ -10211,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==} @@ -10570,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: @@ -11008,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: @@ -11098,17 +11145,17 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.4.1: - resolution: {integrity: sha512-y1bBT0CRCMMfdjyqX1e5zCygLgEEr4KJV1qP6GSUReHl90bmcQaAWjZygHPfQ8K63f1eR8IuivuZMwmCg3zT2Q==} + 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.4.1: - resolution: {integrity: sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.24.0} + 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: @@ -11171,8 +11218,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.53.0: - resolution: {integrity: sha512-7dhHkSamGS2vtoBmIW2hRab+gl5Z60alEHZB4910ePqqJNxAWnDAxsofVmlZ2tREmWyHNE+A1nCKwICAquoD2A==} + svelte@5.53.7: + resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11211,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==} @@ -11246,8 +11293,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tailwindcss@4.2.0: - resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -11300,8 +11347,8 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@7.0.1: - resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} testcontainers@11.12.0: @@ -11384,6 +11431,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -11529,8 +11580,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.56.0: - resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -11868,6 +11919,40 @@ packages: jsdom: optional: true + 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.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -12371,11 +12456,11 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/schematics-cli@19.2.19(@types/node@24.10.13)(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.10.13) + '@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 @@ -12432,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 @@ -12447,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: @@ -12501,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 @@ -12523,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': {} @@ -12548,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 @@ -12562,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: @@ -12935,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 @@ -13115,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)': @@ -13150,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 @@ -13279,261 +13364,261 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.6)': + '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.6)': + '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.8)': dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.6)': + '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-color-function@4.0.12(postcss@8.5.6)': + '@csstools/postcss-color-function@4.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.6)': + '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.6)': + '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.6)': + '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.6)': + '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.6)': + '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.6)': + '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.8)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.6)': + '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.6)': + '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.6)': + '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.6)': + '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.8)': dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-initial@2.0.1(postcss@8.5.6)': + '@csstools/postcss-initial@2.0.1(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.6)': + '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.8)': dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.6)': + '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.6)': + '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.8)': dependencies: '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.6)': + '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.6)': + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.6)': + '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.8)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-normalize-display-values@4.0.0(postcss@8.5.6)': + '@csstools/postcss-normalize-display-values@4.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.6)': + '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.6)': + '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.6)': + '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-random-function@2.0.1(postcss@8.5.6)': + '@csstools/postcss-random-function@2.0.1(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.6)': + '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.6)': + '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.6)': + '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.6)': + '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.6)': + '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.6)': + '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.8)': dependencies: '@csstools/color-helpers': 5.1.0 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.6)': + '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-unset-value@4.0.0(postcss@8.5.6)': + '@csstools/postcss-unset-value@4.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.1)': dependencies: @@ -13543,9 +13628,9 @@ snapshots: dependencies: postcss-selector-parser: 7.1.1 - '@csstools/utilities@2.0.0(postcss@8.5.6)': + '@csstools/utilities@2.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 '@discoveryjs/json-ext@0.5.7': {} @@ -13614,14 +13699,14 @@ snapshots: copy-webpack-plugin: 11.0.0(webpack@5.104.1) css-loader: 6.11.0(webpack@5.104.1) css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.104.1) - cssnano: 6.1.2(postcss@8.5.6) + cssnano: 6.1.2(postcss@8.5.8) file-loader: 6.2.0(webpack@5.104.1) html-minifier-terser: 7.2.0 mini-css-extract-plugin: 2.9.4(webpack@5.104.1) null-loader: 4.0.1(webpack@5.104.1) - postcss: 8.5.6 - postcss-loader: 7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1) - postcss-preset-env: 10.5.0(postcss@8.5.6) + postcss: 8.5.8 + postcss-loader: 7.3.4(postcss@8.5.8)(typescript@5.9.3)(webpack@5.104.1) + postcss-preset-env: 10.5.0(postcss@8.5.8) terser-webpack-plugin: 5.3.16(webpack@5.104.1) tslib: 2.8.1 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.104.1))(webpack@5.104.1) @@ -13708,9 +13793,9 @@ snapshots: '@docusaurus/cssnano-preset@3.9.2': dependencies: - cssnano-preset-advanced: 6.1.2(postcss@8.5.6) - postcss: 8.5.6 - postcss-sort-media-queries: 5.2.0(postcss@8.5.6) + cssnano-preset-advanced: 6.1.2(postcss@8.5.8) + postcss: 8.5.8 + postcss-sort-media-queries: 5.2.0(postcss@8.5.8) tslib: 2.8.1 '@docusaurus/logger@3.9.2': @@ -14142,7 +14227,7 @@ snapshots: infima: 0.2.0-alpha.45 lodash: 4.17.23 nprogress: 0.2.0 - postcss: 8.5.6 + postcss: 8.5.8 prism-react-renderer: 2.4.1(react@18.3.1) prismjs: 1.30.0 react: 18.3.1 @@ -14597,7 +14682,7 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color @@ -14707,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.3)(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.3)(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.3)(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': @@ -14855,175 +14940,176 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/sql-tools@0.2.0': + '@immich/sql-tools@0.3.2': dependencies: + 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.0)': + '@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.0 + svelte: 5.53.7 - '@immich/ui@0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0)': + '@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.0) + '@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.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0) + 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.0 + 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.0) - tailwindcss: 4.2.0 + 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.10.13)': + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 - '@inquirer/confirm@5.1.21(@types/node@24.10.13)': + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 - '@inquirer/core@10.3.2(@types/node@24.10.13)': + '@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.10.13) + '@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.10.13 + '@types/node': 24.12.0 - '@inquirer/editor@4.2.23(@types/node@24.10.13)': + '@inquirer/editor@4.2.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/external-editor': 1.0.3(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@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.10.13 + '@types/node': 24.12.0 - '@inquirer/expand@4.0.23(@types/node@24.10.13)': + '@inquirer/expand@4.0.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@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.10.13 + '@types/node': 24.12.0 - '@inquirer/external-editor@1.0.3(@types/node@24.10.13)': + '@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.10.13 + '@types/node': 24.12.0 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.10.13)': + '@inquirer/input@4.3.1(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 - '@inquirer/number@3.0.23(@types/node@24.10.13)': + '@inquirer/number@3.0.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 - '@inquirer/password@4.0.23(@types/node@24.10.13)': + '@inquirer/password@4.0.23(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 - '@inquirer/prompts@7.10.1(@types/node@24.10.13)': + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.10.13) - '@inquirer/confirm': 5.1.21(@types/node@24.10.13) - '@inquirer/editor': 4.2.23(@types/node@24.10.13) - '@inquirer/expand': 4.0.23(@types/node@24.10.13) - '@inquirer/input': 4.3.1(@types/node@24.10.13) - '@inquirer/number': 3.0.23(@types/node@24.10.13) - '@inquirer/password': 4.0.23(@types/node@24.10.13) - '@inquirer/rawlist': 4.1.11(@types/node@24.10.13) - '@inquirer/search': 3.2.2(@types/node@24.10.13) - '@inquirer/select': 4.4.2(@types/node@24.10.13) + '@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.10.13 + '@types/node': 24.12.0 - '@inquirer/prompts@7.3.2(@types/node@24.10.13)': + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.10.13) - '@inquirer/confirm': 5.1.21(@types/node@24.10.13) - '@inquirer/editor': 4.2.23(@types/node@24.10.13) - '@inquirer/expand': 4.0.23(@types/node@24.10.13) - '@inquirer/input': 4.3.1(@types/node@24.10.13) - '@inquirer/number': 3.0.23(@types/node@24.10.13) - '@inquirer/password': 4.0.23(@types/node@24.10.13) - '@inquirer/rawlist': 4.1.11(@types/node@24.10.13) - '@inquirer/search': 3.2.2(@types/node@24.10.13) - '@inquirer/select': 4.4.2(@types/node@24.10.13) + '@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.10.13 + '@types/node': 24.12.0 - '@inquirer/rawlist@4.1.11(@types/node@24.10.13)': + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@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.10.13 + '@types/node': 24.12.0 - '@inquirer/search@3.2.2(@types/node@24.10.13)': + '@inquirer/search@3.2.2(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 - '@inquirer/select@4.4.2(@types/node@24.10.13)': + '@inquirer/select@4.4.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 - '@inquirer/type@3.0.10(@types/node@24.10.13)': + '@inquirer/type@3.0.10(@types/node@24.12.0)': optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@internationalized/date@3.10.0': dependencies: @@ -15031,17 +15117,13 @@ snapshots: '@ioredis/commands@1.5.0': {} - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 + '@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 @@ -15061,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.10.13 + '@types/node': 24.12.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -15154,8 +15236,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) browserslist: 4.28.1 transitivePeerDependencies: - eslint @@ -15244,7 +15326,7 @@ snapshots: '@maplibre/geojson-vt@5.0.4': {} - '@maplibre/maplibre-gl-style-spec@24.4.1': + '@maplibre/maplibre-gl-style-spec@24.6.0': dependencies: '@mapbox/jsonlint-lines-primitives': 2.0.2 '@mapbox/unitbezier': 0.0.1 @@ -15258,7 +15340,7 @@ snapshots: dependencies: '@mapbox/point-geometry': 1.1.0 - '@maplibre/vt-pbf@4.2.1': + '@maplibre/vt-pbf@4.3.0': dependencies: '@mapbox/point-geometry': 1.1.0 '@mapbox/vector-tile': 2.0.4 @@ -15342,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.3)(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.3)(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.3)(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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.69.3)': + '@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.3)(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.3)(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.3)(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.69.3 + '@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.11(@swc/helpers@0.5.17))(@types/node@24.10.13)': + '@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.10.13)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@24.10.13) + '@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.11(@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.11(@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.11(@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.3)(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 @@ -15395,13 +15477,13 @@ snapshots: uid: 2.0.2 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.3 + 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.3)(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.3)(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 @@ -15411,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.3)(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.3)(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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(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.3)(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.3 + 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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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 @@ -15446,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.3)(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.3)(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.3)(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)': @@ -15463,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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(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.3)(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.3)(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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(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 @@ -15476,27 +15558,27 @@ snapshots: swagger-ui-dist: 5.31.0 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.3 + class-validator: 0.15.1 - '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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': {} @@ -15532,292 +15614,293 @@ 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.39.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/semantic-conventions': 1.39.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/semantic-conventions': 1.39.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)': dependencies: '@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/semantic-conventions': 1.39.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.39.0 + '@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/semantic-conventions': 1.39.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/semantic-conventions': 1.39.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 '@types/pg-pool': 2.0.7 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/semantic-conventions': 1.39.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/semantic-conventions': 1.39.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/semantic-conventions': 1.39.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.39.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: @@ -15912,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 @@ -16217,29 +16300,29 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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/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.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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': 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.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(vite@7.3.1(@types/node@25.3.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.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.0)(vite@7.3.1(@types/node@25.3.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': 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.0 - svelte-parse-markup: 0.1.5(svelte@5.53.0) - vite: 7.3.1(@types/node@25.3.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-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.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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@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.0)(vite@7.3.1(@types/node@25.3.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': 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 @@ -16250,30 +16333,30 @@ snapshots: mrmime: 2.0.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.53.0 - vite: 7.3.1(@types/node@25.3.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) 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.0)(vite@7.3.1(@types/node@25.3.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.0)(vite@7.3.1(@types/node@25.3.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-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.0)(vite@7.3.1(@types/node@25.3.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': 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.0 - vite: 7.3.1(@types/node@25.3.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) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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@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.0)(vite@7.3.1(@types/node@25.3.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.0)(vite@7.3.1(@types/node@25.3.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-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.0 - vite: 7.3.1(@types/node@25.3.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.3.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) + 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 @@ -16334,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))': @@ -16370,51 +16453,51 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.15.11': + '@swc/core-darwin-arm64@1.15.18': optional: true - '@swc/core-darwin-x64@1.15.11': + '@swc/core-darwin-x64@1.15.18': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.11': + '@swc/core-linux-arm-gnueabihf@1.15.18': optional: true - '@swc/core-linux-arm64-gnu@1.15.11': + '@swc/core-linux-arm64-gnu@1.15.18': optional: true - '@swc/core-linux-arm64-musl@1.15.11': + '@swc/core-linux-arm64-musl@1.15.18': optional: true - '@swc/core-linux-x64-gnu@1.15.11': + '@swc/core-linux-x64-gnu@1.15.18': optional: true - '@swc/core-linux-x64-musl@1.15.11': + '@swc/core-linux-x64-musl@1.15.18': optional: true - '@swc/core-win32-arm64-msvc@1.15.11': + '@swc/core-win32-arm64-msvc@1.15.18': optional: true - '@swc/core-win32-ia32-msvc@1.15.11': + '@swc/core-win32-ia32-msvc@1.15.18': optional: true - '@swc/core-win32-x64-msvc@1.15.11': + '@swc/core-win32-x64-msvc@1.15.18': optional: true - '@swc/core@1.15.11(@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.11 - '@swc/core-darwin-x64': 1.15.11 - '@swc/core-linux-arm-gnueabihf': 1.15.11 - '@swc/core-linux-arm64-gnu': 1.15.11 - '@swc/core-linux-arm64-musl': 1.15.11 - '@swc/core-linux-x64-gnu': 1.15.11 - '@swc/core-linux-x64-musl': 1.15.11 - '@swc/core-win32-arm64-msvc': 1.15.11 - '@swc/core-win32-ia32-msvc': 1.15.11 - '@swc/core-win32-x64-msvc': 1.15.11 + '@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': {} @@ -16431,73 +16514,73 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/node@4.2.0': + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 jiti: 2.6.1 lightningcss: 1.31.1 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.0 + tailwindcss: 4.2.1 - '@tailwindcss/oxide-android-arm64@4.2.0': + '@tailwindcss/oxide-android-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.0': + '@tailwindcss/oxide-darwin-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.0': + '@tailwindcss/oxide-darwin-x64@4.2.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.0': + '@tailwindcss/oxide-freebsd-x64@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.0': + '@tailwindcss/oxide-linux-x64-musl@4.2.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.0': + '@tailwindcss/oxide-wasm32-wasi@4.2.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': optional: true - '@tailwindcss/oxide@4.2.0': + '@tailwindcss/oxide@4.2.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-x64': 4.2.0 - '@tailwindcss/oxide-freebsd-x64': 4.2.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-x64-musl': 4.2.0 - '@tailwindcss/oxide-wasm32-wasi': 4.2.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@25.3.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@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.0 - '@tailwindcss/oxide': 4.2.0 - tailwindcss: 4.2.0 - vite: 7.3.1(@types/node@25.3.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/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 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/dom@10.4.1': dependencies: @@ -16519,18 +16602,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.53.0)': + '@testing-library/svelte-core@1.0.0(svelte@5.53.7)': dependencies: - svelte: 5.53.0 + svelte: 5.53.7 - '@testing-library/svelte@5.3.1(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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/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.0) - svelte: 5.53.0 + '@testing-library/svelte-core': 1.0.0(svelte@5.53.7) + svelte: 5.53.7 optionalDependencies: - vite: 7.3.1(@types/node@25.3.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: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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) + 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: @@ -16568,7 +16651,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/archiver@7.0.0': dependencies: @@ -16580,16 +16663,16 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/bonjour@3.5.13': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/braces@3.0.5': {} @@ -16611,21 +16694,21 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/compression@1.8.1': dependencies: '@types/express': 5.0.6 - '@types/node': 24.10.13 + '@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.10.13 + '@types/node': 24.12.0 '@types/connect@3.4.38': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/content-disposition@0.5.9': {} @@ -16642,11 +16725,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.6 '@types/keygrip': 1.0.6 - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/cors@2.8.19': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/d3-array@3.2.2': {} @@ -16773,13 +16856,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 24.10.13 + '@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.10.13 + '@types/node': 24.12.0 '@types/ssh2': 1.15.5 '@types/dom-to-image@2.6.7': {} @@ -16804,14 +16887,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 24.10.13 + '@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.10.13 + '@types/node': 24.12.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -16837,7 +16920,7 @@ snapshots: '@types/fluent-ffmpeg@2.1.28': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/geojson@7946.0.16': {} @@ -16865,7 +16948,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/inquirer@8.2.12': dependencies: @@ -16889,7 +16972,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/justified-layout@4.1.4': {} @@ -16908,7 +16991,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.9 - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/leaflet@1.9.21': dependencies: @@ -16916,9 +16999,9 @@ snapshots: '@types/lodash-es@4.17.12': dependencies: - '@types/lodash': 4.17.23 + '@types/lodash': 4.17.24 - '@types/lodash@4.17.23': {} + '@types/lodash@4.17.24': {} '@types/luxon@3.7.1': {} @@ -16938,17 +17021,17 @@ snapshots: '@types/mock-fs@4.13.4': dependencies: - '@types/node': 24.10.13 + '@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.10.13 + '@types/node': 24.12.0 '@types/node@17.0.45': {} @@ -16956,54 +17039,54 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@24.10.13': + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 - '@types/node@25.3.0': + '@types/node@25.4.0': dependencies: undici-types: 7.18.2 optional: true - '@types/nodemailer@7.0.10': + '@types/nodemailer@7.0.11': dependencies: - '@types/node': 24.10.13 + '@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.10.13 + '@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.10.13 - pg-protocol: 1.11.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.10.13 - pg-protocol: 1.11.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.10.13 + '@types/node': 24.12.0 '@types/prismjs@1.26.5': {} '@types/qrcode@1.5.6': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/qs@6.14.0': {} @@ -17032,28 +17115,28 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 24.10.13 + '@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.10.13 + '@types/node': 24.12.0 '@types/semver@7.7.1': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/send@1.2.1': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/serve-index@1.9.4': dependencies: @@ -17062,25 +17145,25 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.10.13 + '@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.10.13 + '@types/node': 24.12.0 '@types/sockjs@0.3.36': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/ssh2@0.5.52': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -17091,7 +17174,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.10.13 + '@types/node': 24.12.0 form-data: 4.0.5 '@types/supercluster@7.1.3': @@ -17105,7 +17188,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/trusted-types@2.0.7': {} @@ -17121,7 +17204,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/yargs-parser@21.0.3': {} @@ -17129,14 +17212,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 eslint: 10.0.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -17145,41 +17228,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.56.0': + '@typescript-eslint/scope-manager@8.56.1': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 - '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 10.0.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17187,16 +17270,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.56.0': {} + '@typescript-eslint/types@8.56.1': {} - '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - minimatch: 9.0.6 + minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17204,31 +17287,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.56.0': + '@typescript-eslint/visitor-keys@8.56.1': dependencies: - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.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@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 @@ -17237,30 +17320,39 @@ snapshots: magic-string: 0.30.21 magicast: 0.3.5 std-env: 3.10.0 - test-exclude: 7.0.1 + test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.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.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@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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.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: - '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.8 - debug: 4.4.3 + '@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 - magic-string: 0.30.21 - magicast: 0.3.5 + magicast: 0.5.2 + obug: 2.1.1 std-env: 3.10.0 - test-exclude: 7.0.1 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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 + tinyrainbow: 3.0.3 + 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.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.18 + ast-v8-to-istanbul: 0.3.12 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + 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: @@ -17270,48 +17362,87 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.13)(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/expect@4.0.18': dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@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@25.3.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@25.3.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.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.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + 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.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.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + 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.18': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.0.18': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -17396,10 +17527,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.53.0)': + '@zoom-image/svelte@0.3.9(svelte@5.53.7)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.53.0 + svelte: 5.53.7 abbrev@1.1.1: {} @@ -17468,22 +17599,15 @@ snapshots: optionalDependencies: ajv: 8.17.1 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.14.0): dependencies: - ajv: 6.12.6 + ajv: 6.14.0 ajv-keywords@5.1.0(ajv@8.18.0): dependencies: ajv: 8.18.0 fast-deep-equal: 3.1.3 - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -17604,6 +17728,8 @@ snapshots: dependencies: dequal: 2.0.3 + aria-query@5.3.1: {} + aria-query@5.3.2: {} array-flatten@1.1.1: {} @@ -17626,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: {} @@ -17646,13 +17772,13 @@ snapshots: dependencies: immediate: 3.3.0 - autoprefixer@10.4.24(postcss@8.5.6): + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001776 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 axobject-query@4.1.0: {} @@ -17744,7 +17870,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.10.0: {} batch-cluster@17.3.1: {} @@ -17768,15 +17894,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0): + 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.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0) - svelte: 5.53.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0) + 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' @@ -17856,7 +17982,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.3: + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -17866,9 +17992,9 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001774 - electron-to-chromium: 1.5.286 + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001776 + electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -17893,10 +18019,10 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.69.3: + bullmq@5.70.4: dependencies: cron-parser: 4.9.0 - ioredis: 5.9.2 + ioredis: 5.9.3 msgpackr: 1.11.5 node-abort-controller: 3.1.1 semver: 7.7.4 @@ -17984,11 +18110,11 @@ snapshots: caniuse-api@3.0.0: dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001776 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001774: {} + caniuse-lite@1.0.30001776: {} canvas@2.11.2: dependencies: @@ -18015,11 +18141,13 @@ snapshots: chai@5.3.3: dependencies: assertion-error: 2.0.1 - check-error: 2.1.1 + check-error: 2.1.3 deep-eql: 5.0.2 loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -18041,7 +18169,7 @@ snapshots: chardet@2.1.1: {} - check-error@2.1.1: {} + check-error@2.1.3: {} cheerio-select@2.1.0: dependencies: @@ -18112,10 +18240,10 @@ snapshots: class-transformer@0.5.1: {} - class-validator@0.14.3: + class-validator@0.15.1: dependencies: '@types/validator': 13.15.10 - libphonenumber-js: 1.12.31 + libphonenumber-js: 1.12.38 validator: 13.15.26 clean-css@5.3.3: @@ -18224,6 +18352,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -18412,30 +18542,30 @@ snapshots: dependencies: type-fest: 1.4.0 - css-blank-pseudo@7.0.1(postcss@8.5.6): + css-blank-pseudo@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - css-declaration-sorter@7.3.0(postcss@8.5.6): + css-declaration-sorter@7.3.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - css-has-pseudo@7.0.3(postcss@8.5.6): + css-has-pseudo@7.0.3(postcss@8.5.8): dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 css-loader@6.11.0(webpack@5.104.1): dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) - postcss-modules-scope: 3.2.1(postcss@8.5.6) - postcss-modules-values: 4.0.0(postcss@8.5.6) + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.8) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.8) + postcss-modules-scope: 3.2.1(postcss@8.5.8) + postcss-modules-values: 4.0.0(postcss@8.5.8) postcss-value-parser: 4.2.0 semver: 7.7.4 optionalDependencies: @@ -18444,18 +18574,18 @@ snapshots: css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.104.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 - cssnano: 6.1.2(postcss@8.5.6) + cssnano: 6.1.2(postcss@8.5.8) jest-worker: 29.7.0 - postcss: 8.5.6 + postcss: 8.5.8 schema-utils: 4.3.3 serialize-javascript: 6.0.2 webpack: 5.104.1 optionalDependencies: clean-css: 5.3.3 - css-prefers-color-scheme@10.0.0(postcss@8.5.6): + css-prefers-color-scheme@10.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 css-select@4.3.0: dependencies: @@ -18493,60 +18623,60 @@ snapshots: cssesc@3.0.0: {} - cssnano-preset-advanced@6.1.2(postcss@8.5.6): + cssnano-preset-advanced@6.1.2(postcss@8.5.8): dependencies: - autoprefixer: 10.4.24(postcss@8.5.6) + autoprefixer: 10.4.27(postcss@8.5.8) browserslist: 4.28.1 - cssnano-preset-default: 6.1.2(postcss@8.5.6) - postcss: 8.5.6 - postcss-discard-unused: 6.0.5(postcss@8.5.6) - postcss-merge-idents: 6.0.3(postcss@8.5.6) - postcss-reduce-idents: 6.0.3(postcss@8.5.6) - postcss-zindex: 6.0.2(postcss@8.5.6) + cssnano-preset-default: 6.1.2(postcss@8.5.8) + postcss: 8.5.8 + postcss-discard-unused: 6.0.5(postcss@8.5.8) + postcss-merge-idents: 6.0.3(postcss@8.5.8) + postcss-reduce-idents: 6.0.3(postcss@8.5.8) + postcss-zindex: 6.0.2(postcss@8.5.8) - cssnano-preset-default@6.1.2(postcss@8.5.6): + cssnano-preset-default@6.1.2(postcss@8.5.8): dependencies: browserslist: 4.28.1 - css-declaration-sorter: 7.3.0(postcss@8.5.6) - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 - postcss-calc: 9.0.1(postcss@8.5.6) - postcss-colormin: 6.1.0(postcss@8.5.6) - postcss-convert-values: 6.1.0(postcss@8.5.6) - postcss-discard-comments: 6.0.2(postcss@8.5.6) - postcss-discard-duplicates: 6.0.3(postcss@8.5.6) - postcss-discard-empty: 6.0.3(postcss@8.5.6) - postcss-discard-overridden: 6.0.2(postcss@8.5.6) - postcss-merge-longhand: 6.0.5(postcss@8.5.6) - postcss-merge-rules: 6.1.1(postcss@8.5.6) - postcss-minify-font-values: 6.1.0(postcss@8.5.6) - postcss-minify-gradients: 6.0.3(postcss@8.5.6) - postcss-minify-params: 6.1.0(postcss@8.5.6) - postcss-minify-selectors: 6.0.4(postcss@8.5.6) - postcss-normalize-charset: 6.0.2(postcss@8.5.6) - postcss-normalize-display-values: 6.0.2(postcss@8.5.6) - postcss-normalize-positions: 6.0.2(postcss@8.5.6) - postcss-normalize-repeat-style: 6.0.2(postcss@8.5.6) - postcss-normalize-string: 6.0.2(postcss@8.5.6) - postcss-normalize-timing-functions: 6.0.2(postcss@8.5.6) - postcss-normalize-unicode: 6.1.0(postcss@8.5.6) - postcss-normalize-url: 6.0.2(postcss@8.5.6) - postcss-normalize-whitespace: 6.0.2(postcss@8.5.6) - postcss-ordered-values: 6.0.2(postcss@8.5.6) - postcss-reduce-initial: 6.1.0(postcss@8.5.6) - postcss-reduce-transforms: 6.0.2(postcss@8.5.6) - postcss-svgo: 6.0.3(postcss@8.5.6) - postcss-unique-selectors: 6.0.4(postcss@8.5.6) + css-declaration-sorter: 7.3.0(postcss@8.5.8) + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 + postcss-calc: 9.0.1(postcss@8.5.8) + postcss-colormin: 6.1.0(postcss@8.5.8) + postcss-convert-values: 6.1.0(postcss@8.5.8) + postcss-discard-comments: 6.0.2(postcss@8.5.8) + postcss-discard-duplicates: 6.0.3(postcss@8.5.8) + postcss-discard-empty: 6.0.3(postcss@8.5.8) + postcss-discard-overridden: 6.0.2(postcss@8.5.8) + postcss-merge-longhand: 6.0.5(postcss@8.5.8) + postcss-merge-rules: 6.1.1(postcss@8.5.8) + postcss-minify-font-values: 6.1.0(postcss@8.5.8) + postcss-minify-gradients: 6.0.3(postcss@8.5.8) + postcss-minify-params: 6.1.0(postcss@8.5.8) + postcss-minify-selectors: 6.0.4(postcss@8.5.8) + postcss-normalize-charset: 6.0.2(postcss@8.5.8) + postcss-normalize-display-values: 6.0.2(postcss@8.5.8) + postcss-normalize-positions: 6.0.2(postcss@8.5.8) + postcss-normalize-repeat-style: 6.0.2(postcss@8.5.8) + postcss-normalize-string: 6.0.2(postcss@8.5.8) + postcss-normalize-timing-functions: 6.0.2(postcss@8.5.8) + postcss-normalize-unicode: 6.1.0(postcss@8.5.8) + postcss-normalize-url: 6.0.2(postcss@8.5.8) + postcss-normalize-whitespace: 6.0.2(postcss@8.5.8) + postcss-ordered-values: 6.0.2(postcss@8.5.8) + postcss-reduce-initial: 6.1.0(postcss@8.5.8) + postcss-reduce-transforms: 6.0.2(postcss@8.5.8) + postcss-svgo: 6.0.3(postcss@8.5.8) + postcss-unique-selectors: 6.0.4(postcss@8.5.8) - cssnano-utils@4.0.2(postcss@8.5.6): + cssnano-utils@4.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - cssnano@6.1.2(postcss@8.5.6): + cssnano@6.1.2(postcss@8.5.8): dependencies: - cssnano-preset-default: 6.1.2(postcss@8.5.6) + cssnano-preset-default: 6.1.2(postcss@8.5.8) lilconfig: 3.1.3 - postcss: 8.5.6 + postcss: 8.5.8 csso@5.0.5: dependencies: @@ -19018,7 +19148,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.286: {} + electron-to-chromium@1.5.302: {} emoji-regex@10.6.0: {} @@ -19060,7 +19190,7 @@ snapshots: engine.io@6.6.5: dependencies: '@types/cors': 2.8.19 - '@types/node': 24.10.13 + '@types/node': 24.12.0 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -19073,7 +19203,7 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.19.0: + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -19252,12 +19382,11 @@ snapshots: dependencies: eslint: 10.0.2(jiti@2.6.1) - eslint-plugin-compat@6.2.0(eslint@10.0.2(jiti@2.6.1)): + eslint-plugin-compat@6.2.1(eslint@10.0.2(jiti@2.6.1)): dependencies: '@mdn/browser-compat-data': 6.1.5 ast-metadata-inferer: 0.8.1 browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 eslint: 10.0.2(jiti@2.6.1) find-up: 5.0.0 globals: 15.15.0 @@ -19274,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.0): + 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 @@ -19282,13 +19411,13 @@ snapshots: esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 - postcss: 8.5.6 - postcss-load-config: 3.1.4(postcss@8.5.6) - postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss: 8.5.8 + 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.4.1(svelte@5.53.0) + svelte-eslint-parser: 1.6.0(svelte@5.53.7) optionalDependencies: - svelte: 5.53.0 + svelte: 5.53.7 transitivePeerDependencies: - ts-node @@ -19364,7 +19493,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.2 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -19460,7 +19589,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 require-like: 0.1.2 event-emitter@0.3.5: @@ -19494,21 +19623,21 @@ 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.3.0: {} @@ -19753,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.11(@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 @@ -19768,7 +19897,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.11(@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: {} @@ -19930,20 +20059,20 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.1.2 + minimatch: 10.2.4 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.1 glob@13.0.0: dependencies: - minimatch: 10.1.2 + minimatch: 10.2.4 minipass: 7.1.2 path-scurry: 2.0.1 glob@13.0.2: dependencies: - minimatch: 10.1.2 + minimatch: 10.2.4 minipass: 7.1.2 path-scurry: 2.0.1 @@ -19964,7 +20093,7 @@ snapshots: globals@16.5.0: {} - globals@17.3.0: {} + globals@17.4.0: {} globalyzer@0.1.0: {} @@ -20031,9 +20160,9 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.6.3: + happy-dom@20.8.3: dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -20285,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 @@ -20399,9 +20535,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.6): + icss-utils@5.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 ieee754@1.2.1: {} @@ -20424,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) @@ -20456,9 +20592,9 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@8.2.7(@types/node@24.10.13): + inquirer@8.2.7(@types/node@24.12.0): dependencies: - '@inquirer/external-editor': 1.0.3(@types/node@24.10.13) + '@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 @@ -20498,9 +20634,9 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.9.2: + ioredis@5.10.0: dependencies: - '@ioredis/commands': 1.5.0 + '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 debug: 4.4.3 denque: 2.1.0 @@ -20643,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 @@ -20695,7 +20831,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.10.13 + '@types/node': 24.12.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -20703,13 +20839,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -20734,6 +20870,8 @@ snapshots: jose@6.1.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -20926,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 @@ -20970,7 +21100,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.12.31: {} + libphonenumber-js@1.12.38: {} lightningcss-android-arm64@1.31.1: optional: true @@ -21146,8 +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.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@3.1.0: @@ -21174,7 +21310,7 @@ snapshots: transitivePeerDependencies: - supports-color - maplibre-gl@5.18.0: + maplibre-gl@5.19.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -21184,9 +21320,9 @@ snapshots: '@mapbox/vector-tile': 2.0.4 '@mapbox/whoots-js': 3.1.0 '@maplibre/geojson-vt': 5.0.4 - '@maplibre/maplibre-gl-style-spec': 24.4.1 + '@maplibre/maplibre-gl-style-spec': 24.6.0 '@maplibre/mlt': 1.1.6 - '@maplibre/vt-pbf': 4.2.1 + '@maplibre/vt-pbf': 4.3.0 '@types/geojson': 7946.0.16 '@types/supercluster': 7.1.3 earcut: 3.0.2 @@ -21815,13 +21951,9 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.1.2: + minimatch@10.2.4: dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@10.2.2: - dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.4 minimatch@3.1.2: dependencies: @@ -21833,7 +21965,7 @@ snapshots: minimatch@9.0.6: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.4 minimist@1.2.8: {} @@ -21882,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: {} @@ -21931,15 +22059,12 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - multer@2.0.2: + multer@2.1.1: 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 multicast-dns@7.2.5: dependencies: @@ -21984,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.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.10.13)(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.3)(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.3)(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.3)(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.10.13) + 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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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.3)(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 @@ -22251,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: {} @@ -22427,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.11.0(pg@8.18.0): + pg-pool@3.13.0(pg@8.20.0): dependencies: - pg: 8.18.0 + pg: 8.20.0 - pg-protocol@1.11.0: {} + pg-protocol@1.13.0: {} pg-types@2.2.0: dependencies: @@ -22445,11 +22570,11 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.18.0: + pg@8.20.0: dependencies: - pg-connection-string: 2.11.0 - pg-pool: 3.11.0(pg@8.18.0) - pg-protocol: 1.11.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: @@ -22521,446 +22646,446 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6): + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-calc@9.0.1(postcss@8.5.6): + postcss-calc@9.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 - postcss-clamp@4.1.0(postcss@8.5.6): + postcss-clamp@4.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-color-functional-notation@7.0.12(postcss@8.5.6): + postcss-color-functional-notation@7.0.12(postcss@8.5.8): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - postcss-color-hex-alpha@10.0.0(postcss@8.5.6): + postcss-color-hex-alpha@10.0.0(postcss@8.5.8): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-color-rebeccapurple@10.0.0(postcss@8.5.6): + postcss-color-rebeccapurple@10.0.0(postcss@8.5.8): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-colormin@6.1.0(postcss@8.5.6): + postcss-colormin@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 colord: 2.9.3 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-convert-values@6.1.0(postcss@8.5.6): + postcss-convert-values@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-custom-media@11.0.6(postcss@8.5.6): + postcss-custom-media@11.0.6(postcss@8.5.8): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.6 + postcss: 8.5.8 - postcss-custom-properties@14.0.6(postcss@8.5.6): + postcss-custom-properties@14.0.6(postcss@8.5.8): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-custom-selectors@8.0.5(postcss@8.5.6): + postcss-custom-selectors@8.0.5(postcss@8.5.8): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-dir-pseudo-class@9.0.1(postcss@8.5.6): + postcss-dir-pseudo-class@9.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-discard-comments@6.0.2(postcss@8.5.6): + postcss-discard-comments@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-duplicates@6.0.3(postcss@8.5.6): + postcss-discard-duplicates@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-empty@6.0.3(postcss@8.5.6): + postcss-discard-empty@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-overridden@6.0.2(postcss@8.5.6): + postcss-discard-overridden@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-unused@6.0.5(postcss@8.5.6): + postcss-discard-unused@6.0.5(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-double-position-gradients@6.0.4(postcss@8.5.6): + postcss-double-position-gradients@6.0.4(postcss@8.5.8): dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-focus-visible@10.0.1(postcss@8.5.6): + postcss-focus-visible@10.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-focus-within@9.0.1(postcss@8.5.6): + postcss-focus-within@9.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-font-variant@5.0.0(postcss@8.5.6): + postcss-font-variant@5.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-gap-properties@6.0.0(postcss@8.5.6): + postcss-gap-properties@6.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-image-set-function@7.0.0(postcss@8.5.6): + postcss-image-set-function@7.0.0(postcss@8.5.8): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-import@15.1.0(postcss@8.5.6): + postcss-import@15.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.11 - postcss-js@4.1.0(postcss@8.5.6): + postcss-js@4.1.0(postcss@8.5.8): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.6 + postcss: 8.5.8 - postcss-lab-function@7.0.12(postcss@8.5.6): + postcss-lab-function@7.0.12(postcss@8.5.8): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - postcss-load-config@3.1.4(postcss@8.5.6): + postcss-load-config@3.1.4(postcss@8.5.8): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.6 + postcss: 8.5.8 tsx: 4.21.0 yaml: 2.8.2 - postcss-loader@7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1): + postcss-loader@7.3.4(postcss@8.5.8)(typescript@5.9.3)(webpack@5.104.1): dependencies: cosmiconfig: 8.3.6(typescript@5.9.3) jiti: 1.21.7 - postcss: 8.5.6 + postcss: 8.5.8 semver: 7.7.4 webpack: 5.104.1 transitivePeerDependencies: - typescript - postcss-logical@8.1.0(postcss@8.5.6): + postcss-logical@8.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-merge-idents@6.0.3(postcss@8.5.6): + postcss-merge-idents@6.0.3(postcss@8.5.8): dependencies: - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-merge-longhand@6.0.5(postcss@8.5.6): + postcss-merge-longhand@6.0.5(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - stylehacks: 6.1.1(postcss@8.5.6) + stylehacks: 6.1.1(postcss@8.5.8) - postcss-merge-rules@6.1.1(postcss@8.5.6): + postcss-merge-rules@6.1.1(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-minify-font-values@6.1.0(postcss@8.5.6): + postcss-minify-font-values@6.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-gradients@6.0.3(postcss@8.5.6): + postcss-minify-gradients@6.0.3(postcss@8.5.8): dependencies: colord: 2.9.3 - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-params@6.1.0(postcss@8.5.6): + postcss-minify-params@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-selectors@6.0.4(postcss@8.5.6): + postcss-minify-selectors@6.0.4(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-modules-extract-imports@3.1.0(postcss@8.5.6): + postcss-modules-extract-imports@3.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-modules-local-by-default@4.2.0(postcss@8.5.6): + postcss-modules-local-by-default@4.2.0(postcss@8.5.8): dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.6): + postcss-modules-scope@3.2.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-modules-values@4.0.0(postcss@8.5.6): + postcss-modules-values@4.0.0(postcss@8.5.8): dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 - postcss-nested@6.2.0(postcss@8.5.6): + postcss-nested@6.2.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-nesting@13.0.2(postcss@8.5.6): + postcss-nesting@13.0.2(postcss@8.5.8): dependencies: '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.1) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-normalize-charset@6.0.2(postcss@8.5.6): + postcss-normalize-charset@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-normalize-display-values@6.0.2(postcss@8.5.6): + postcss-normalize-display-values@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-positions@6.0.2(postcss@8.5.6): + postcss-normalize-positions@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@6.0.2(postcss@8.5.6): + postcss-normalize-repeat-style@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-string@6.0.2(postcss@8.5.6): + postcss-normalize-string@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@6.0.2(postcss@8.5.6): + postcss-normalize-timing-functions@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@6.1.0(postcss@8.5.6): + postcss-normalize-unicode@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-url@6.0.2(postcss@8.5.6): + postcss-normalize-url@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@6.0.2(postcss@8.5.6): + postcss-normalize-whitespace@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-opacity-percentage@3.0.0(postcss@8.5.6): + postcss-opacity-percentage@3.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-ordered-values@6.0.2(postcss@8.5.6): + postcss-ordered-values@6.0.2(postcss@8.5.8): dependencies: - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-overflow-shorthand@6.0.0(postcss@8.5.6): + postcss-overflow-shorthand@6.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-page-break@3.0.4(postcss@8.5.6): + postcss-page-break@3.0.4(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-place@10.0.0(postcss@8.5.6): + postcss-place@10.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-preset-env@10.5.0(postcss@8.5.6): + postcss-preset-env@10.5.0(postcss@8.5.8): dependencies: - '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.6) - '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.6) - '@csstools/postcss-color-function': 4.0.12(postcss@8.5.6) - '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.6) - '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.6) - '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.6) - '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.6) - '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.6) - '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.6) - '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.6) - '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.6) - '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.6) - '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.6) - '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.6) - '@csstools/postcss-initial': 2.0.1(postcss@8.5.6) - '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.6) - '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.6) - '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.6) - '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.6) - '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.6) - '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.6) - '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.6) - '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.6) - '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.6) - '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.6) - '@csstools/postcss-normalize-display-values': 4.0.0(postcss@8.5.6) - '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.6) - '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.6) - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/postcss-random-function': 2.0.1(postcss@8.5.6) - '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.6) - '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.6) - '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.6) - '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.6) - '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.6) - '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.6) - '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.6) - '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.6) - autoprefixer: 10.4.24(postcss@8.5.6) + '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.8) + '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.8) + '@csstools/postcss-color-function': 4.0.12(postcss@8.5.8) + '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.8) + '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.8) + '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.8) + '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.8) + '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.8) + '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.8) + '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.8) + '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.8) + '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.8) + '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.8) + '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.8) + '@csstools/postcss-initial': 2.0.1(postcss@8.5.8) + '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.8) + '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.8) + '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.8) + '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.8) + '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.8) + '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.8) + '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.8) + '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.8) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.8) + '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.8) + '@csstools/postcss-normalize-display-values': 4.0.0(postcss@8.5.8) + '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.8) + '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.8) + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/postcss-random-function': 2.0.1(postcss@8.5.8) + '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.8) + '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.8) + '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.8) + '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.8) + '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.8) + '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.8) + '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.8) + '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.8) + autoprefixer: 10.4.27(postcss@8.5.8) browserslist: 4.28.1 - css-blank-pseudo: 7.0.1(postcss@8.5.6) - css-has-pseudo: 7.0.3(postcss@8.5.6) - css-prefers-color-scheme: 10.0.0(postcss@8.5.6) + css-blank-pseudo: 7.0.1(postcss@8.5.8) + css-has-pseudo: 7.0.3(postcss@8.5.8) + css-prefers-color-scheme: 10.0.0(postcss@8.5.8) cssdb: 8.5.2 - postcss: 8.5.6 - postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.6) - postcss-clamp: 4.1.0(postcss@8.5.6) - postcss-color-functional-notation: 7.0.12(postcss@8.5.6) - postcss-color-hex-alpha: 10.0.0(postcss@8.5.6) - postcss-color-rebeccapurple: 10.0.0(postcss@8.5.6) - postcss-custom-media: 11.0.6(postcss@8.5.6) - postcss-custom-properties: 14.0.6(postcss@8.5.6) - postcss-custom-selectors: 8.0.5(postcss@8.5.6) - postcss-dir-pseudo-class: 9.0.1(postcss@8.5.6) - postcss-double-position-gradients: 6.0.4(postcss@8.5.6) - postcss-focus-visible: 10.0.1(postcss@8.5.6) - postcss-focus-within: 9.0.1(postcss@8.5.6) - postcss-font-variant: 5.0.0(postcss@8.5.6) - postcss-gap-properties: 6.0.0(postcss@8.5.6) - postcss-image-set-function: 7.0.0(postcss@8.5.6) - postcss-lab-function: 7.0.12(postcss@8.5.6) - postcss-logical: 8.1.0(postcss@8.5.6) - postcss-nesting: 13.0.2(postcss@8.5.6) - postcss-opacity-percentage: 3.0.0(postcss@8.5.6) - postcss-overflow-shorthand: 6.0.0(postcss@8.5.6) - postcss-page-break: 3.0.4(postcss@8.5.6) - postcss-place: 10.0.0(postcss@8.5.6) - postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.6) - postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.6) - postcss-selector-not: 8.0.1(postcss@8.5.6) + postcss: 8.5.8 + postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.8) + postcss-clamp: 4.1.0(postcss@8.5.8) + postcss-color-functional-notation: 7.0.12(postcss@8.5.8) + postcss-color-hex-alpha: 10.0.0(postcss@8.5.8) + postcss-color-rebeccapurple: 10.0.0(postcss@8.5.8) + postcss-custom-media: 11.0.6(postcss@8.5.8) + postcss-custom-properties: 14.0.6(postcss@8.5.8) + postcss-custom-selectors: 8.0.5(postcss@8.5.8) + postcss-dir-pseudo-class: 9.0.1(postcss@8.5.8) + postcss-double-position-gradients: 6.0.4(postcss@8.5.8) + postcss-focus-visible: 10.0.1(postcss@8.5.8) + postcss-focus-within: 9.0.1(postcss@8.5.8) + postcss-font-variant: 5.0.0(postcss@8.5.8) + postcss-gap-properties: 6.0.0(postcss@8.5.8) + postcss-image-set-function: 7.0.0(postcss@8.5.8) + postcss-lab-function: 7.0.12(postcss@8.5.8) + postcss-logical: 8.1.0(postcss@8.5.8) + postcss-nesting: 13.0.2(postcss@8.5.8) + postcss-opacity-percentage: 3.0.0(postcss@8.5.8) + postcss-overflow-shorthand: 6.0.0(postcss@8.5.8) + postcss-page-break: 3.0.4(postcss@8.5.8) + postcss-place: 10.0.0(postcss@8.5.8) + postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.8) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.8) + postcss-selector-not: 8.0.1(postcss@8.5.8) - postcss-pseudo-class-any-link@10.0.1(postcss@8.5.6): + postcss-pseudo-class-any-link@10.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-reduce-idents@6.0.3(postcss@8.5.6): + postcss-reduce-idents@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-reduce-initial@6.1.0(postcss@8.5.6): + postcss-reduce-initial@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 - postcss: 8.5.6 + postcss: 8.5.8 - postcss-reduce-transforms@6.0.2(postcss@8.5.6): + postcss-reduce-transforms@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-replace-overflow-wrap@4.0.0(postcss@8.5.6): + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-safe-parser@7.0.1(postcss@8.5.6): + postcss-safe-parser@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-scss@4.0.9(postcss@8.5.6): + postcss-scss@4.0.9(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-selector-not@8.0.1(postcss@8.5.6): + postcss-selector-not@8.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-selector-parser@6.1.2: @@ -22973,29 +23098,29 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-sort-media-queries@5.2.0(postcss@8.5.6): + postcss-sort-media-queries@5.2.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 sort-css-media-queries: 2.2.0 - postcss-svgo@6.0.3(postcss@8.5.6): + postcss-svgo@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 svgo: 3.3.2 - postcss-unique-selectors@6.0.4(postcss@8.5.6): + postcss-unique-selectors@6.0.4(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 postcss-value-parser@4.2.0: {} - postcss-zindex@6.0.2(postcss@8.5.6): + postcss-zindex@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -23030,10 +23155,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.0): + prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7): dependencies: prettier: 3.8.1 - svelte: 5.53.0 + svelte: 5.53.7 prettier@3.8.1: {} @@ -23113,22 +23238,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.10.13 - 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.10.13 + '@types/node': 24.12.0 long: 5.3.2 protocol-buffers-schema@3.6.0: {} @@ -23224,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 @@ -23578,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 @@ -23642,7 +23752,7 @@ snapshots: dependencies: escalade: 3.2.0 picocolors: 1.1.1 - postcss: 8.5.6 + postcss: 8.5.8 strip-json-comments: 3.1.1 run-applescript@7.1.0: {} @@ -23653,14 +23763,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0): + 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.0 + svelte: 5.53.7 optionalDependencies: - '@sveltejs/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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': 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: {} @@ -23693,7 +23803,7 @@ snapshots: htmlparser2: 8.0.2 is-plain-object: 5.0.0 parse-srcset: 1.0.2 - postcss: 8.5.6 + postcss: 8.5.8 sass@1.97.1: dependencies: @@ -23721,8 +23831,8 @@ snapshots: schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) schema-utils@4.3.3: dependencies: @@ -24161,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: @@ -24192,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 @@ -24230,10 +24340,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylehacks@6.1.1(postcss@8.5.6): + stylehacks@6.1.1(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 stylis@4.3.6: {} @@ -24284,32 +24394,33 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.53.0): + svelte-awesome@3.3.5(svelte@5.53.7): dependencies: - svelte: 5.53.0 + svelte: 5.53.7 - svelte-check@4.4.1(picomatch@4.0.3)(svelte@5.53.0)(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.0 + svelte: 5.53.7 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.53.0): + svelte-eslint-parser@1.6.0(svelte@5.53.7): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - postcss: 8.5.6 - postcss-scss: 4.0.9(postcss@8.5.6) + postcss: 8.5.8 + postcss-scss: 4.0.9(postcss@8.5.8) postcss-selector-parser: 7.1.1 + semver: 7.7.4 optionalDependencies: - svelte: 5.53.0 + svelte: 5.53.7 svelte-floating-ui@1.5.8: dependencies: @@ -24322,7 +24433,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.53.0): + svelte-i18n@4.0.1(svelte@5.53.7): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24330,10 +24441,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.53.0 + svelte: 5.53.7 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.53.0): + svelte-jsoneditor@3.11.0(svelte@5.53.7): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24360,42 +24471,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.53.0 - svelte-awesome: 3.3.5(svelte@5.53.0) + 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.0): + 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.18.0 + maplibre-gl: 5.19.0 pmtiles: 3.2.1 - svelte: 5.53.0 + svelte: 5.53.7 - svelte-parse-markup@0.1.5(svelte@5.53.0): + svelte-parse-markup@0.1.5(svelte@5.53.7): dependencies: - svelte: 5.53.0 + svelte: 5.53.7 - svelte-persisted-store@0.12.0(svelte@5.53.0): + svelte-persisted-store@0.12.0(svelte@5.53.7): dependencies: - svelte: 5.53.0 + svelte: 5.53.7 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0): + 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.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.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.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.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.0) + 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.0 + svelte: 5.53.7 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.53.0: + svelte@5.53.7: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -24403,7 +24514,7 @@ snapshots: '@types/estree': 1.0.8 '@types/trusted-types': 2.0.7 acorn: 8.16.0 - aria-query: 5.3.2 + aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 devalue: 5.6.3 @@ -24449,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.0): + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): dependencies: - tailwindcss: 4.2.0 + 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: @@ -24487,11 +24598,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) - postcss-nested: 6.2.0(postcss@8.5.6) + postcss: 8.5.8 + postcss-import: 15.1.0(postcss@8.5.8) + postcss-js: 4.1.0(postcss@8.5.8) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2) + postcss-nested: 6.2.0(postcss@8.5.8) postcss-selector-parser: 6.1.2 resolve: 1.22.11 sucrase: 3.35.1 @@ -24499,7 +24610,7 @@ snapshots: - tsx - yaml - tailwindcss@4.2.0: {} + tailwindcss@4.2.1: {} tapable@2.3.0: {} @@ -24564,16 +24675,16 @@ snapshots: - react-native-b4a optional: true - terser-webpack-plugin@5.3.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.11(@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.11(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.15.11(@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: @@ -24591,11 +24702,11 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@7.0.1: + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 9.0.6 + minimatch: 10.2.4 testcontainers@11.12.0: dependencies: @@ -24683,6 +24794,8 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} tldts-core@6.1.86: @@ -24757,7 +24870,7 @@ snapshots: tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 tapable: 2.3.0 tsconfig-paths: 4.2.0 @@ -24809,12 +24922,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -24949,10 +25062,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.9(@swc/core@1.15.11(@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.11(@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: @@ -25086,13 +25199,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@24.10.13)(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.10.13)(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 @@ -25107,47 +25220,26 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.3.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@25.3.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 - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(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.10.13)(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.10.13)(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) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.8 rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -25156,16 +25248,16 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@25.3.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@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) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.8 rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.4.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -25174,19 +25266,19 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.1(@types/node@25.3.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)): optionalDependencies: - vite: 7.3.1(@types/node@25.3.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@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@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.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@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: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.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: 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.10.13)(happy-dom@20.6.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): + 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.10.13)(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 @@ -25204,13 +25296,54 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.10.13)(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.10.13)(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.10.13 - happy-dom: 20.6.3 + '@types/node': 24.12.0 + happy-dom: 20.8.3 + jsdom: 26.1.0(canvas@2.11.2) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + 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.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.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: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.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) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.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 @@ -25221,40 +25354,36 @@ snapshots: - sass-embedded - stylus - sugarss - - supports-color - terser - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.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@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: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.13)(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 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 + '@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.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 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.10.13)(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.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + tinyrainbow: 3.0.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) why-is-node-running: 2.3.0 optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 24.10.13 - happy-dom: 20.6.3 + '@opentelemetry/api': 1.9.0 + '@types/node': 24.12.0 + happy-dom: 20.8.3 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25265,40 +25394,36 @@ snapshots: - sass-embedded - stylus - sugarss - - supports-color - terser - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.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@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: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.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 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 + '@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.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 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.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@25.3.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) + tinyrainbow: 3.0.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) why-is-node-running: 2.3.0 optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 25.3.0 - happy-dom: 20.6.3 + '@opentelemetry/api': 1.9.0 + '@types/node': 25.4.0 + happy-dom: 20.8.3 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25309,7 +25434,6 @@ snapshots: - sass-embedded - stylus - sugarss - - supports-color - terser - tsx - yaml @@ -25457,7 +25581,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -25477,7 +25601,7 @@ snapshots: - esbuild - uglify-js - webpack@5.104.1(@swc/core@1.15.11(@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 @@ -25489,7 +25613,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -25501,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.11(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.11(@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: @@ -25599,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: {} @@ -25618,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/Dockerfile b/server/Dockerfile index a8a8b04713..9cc53c1095 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder +FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ cd plugins && mise run build -FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f +FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03 WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index f778c20afb..f64a1a904b 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,9 +1,9 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev +FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ - COREPACK_HOME=/tmp \ + COREPACK_HOME=/tmp \ PNPM_HOME=/buildcache/pnpm-store RUN npm install --global corepack@latest && \ diff --git a/server/package.json b/server/package.json index 9b1acc91fb..848229b0e9 100644 --- a/server/package.json +++ b/server/package.json @@ -7,8 +7,8 @@ "license": "GNU Affero General Public License version 3", "scripts": { "build": "nest build", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "start": "pnpm run start:dev", "nest": "nest", "start:dev": "nest start --watch --", @@ -22,12 +22,12 @@ "test:cov": "vitest --config test/vitest.config.mjs --coverage", "test:medium": "vitest --config test/vitest.config.medium.mjs", "typeorm": "typeorm", - "migrations:debug": "node ./dist/bin/migrations.js debug", - "migrations:generate": "node ./dist/bin/migrations.js generate", - "migrations:create": "node ./dist/bin/migrations.js create", - "migrations:run": "node ./dist/bin/migrations.js run", - "migrations:revert": "node ./dist/bin/migrations.js revert", - "schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'", + "migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug", + "migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", + "migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", + "migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run", + "migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert", + "schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'", "schema:reset": "pnpm run schema:drop && pnpm run migrations:run", "sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:sql": "node ./dist/bin/sync-sql.js", @@ -35,7 +35,7 @@ }, "dependencies": { "@extism/extism": "2.0.0-rc13", - "@immich/sql-tools": "^0.2.0", + "@immich/sql-tools": "^0.3.2", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", @@ -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.13", + "@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/bin/migrations.ts b/server/src/bin/migrations.ts deleted file mode 100644 index bfa0f1733c..0000000000 --- a/server/src/bin/migrations.ts +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env node -process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; - -import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools'; -import { Kysely, sql } from 'kysely'; -import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; -import { basename, dirname, extname, join } from 'node:path'; -import { ConfigRepository } from 'src/repositories/config.repository'; -import { DatabaseRepository } from 'src/repositories/database.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import 'src/schema'; -import { getKyselyConfig } from 'src/utils/database'; - -const main = async () => { - const command = process.argv[2]; - const path = process.argv[3] || 'src/Migration'; - - switch (command) { - case 'debug': { - await debug(); - return; - } - - case 'run': { - await runMigrations(); - return; - } - - case 'revert': { - await revert(); - return; - } - - case 'query': { - const query = process.argv[3]; - await runQuery(query); - return; - } - - case 'create': { - create(path, [], []); - return; - } - - case 'generate': { - await generate(path); - return; - } - - default: { - console.log(`Usage: - node dist/bin/migrations.js create - node dist/bin/migrations.js generate - node dist/bin/migrations.js run - node dist/bin/migrations.js revert -`); - } - } -}; - -const getDatabaseClient = () => { - const configRepository = new ConfigRepository(); - const { database } = configRepository.getEnv(); - return new Kysely(getKyselyConfig(database.config)); -}; - -const runQuery = async (query: string) => { - const db = getDatabaseClient(); - await sql.raw(query).execute(db); - await db.destroy(); -}; - -const runMigrations = async () => { - const configRepository = new ConfigRepository(); - const logger = LoggingRepository.create(); - const db = getDatabaseClient(); - const databaseRepository = new DatabaseRepository(db, logger, configRepository); - await databaseRepository.runMigrations(); - await db.destroy(); -}; - -const revert = async () => { - const configRepository = new ConfigRepository(); - const logger = LoggingRepository.create(); - const db = getDatabaseClient(); - const databaseRepository = new DatabaseRepository(db, logger, configRepository); - - try { - const migrationName = await databaseRepository.revertLastMigration(); - if (!migrationName) { - console.log('No migrations to revert'); - return; - } - - markMigrationAsReverted(migrationName); - } finally { - await db.destroy(); - } -}; - -const debug = async () => { - const { up } = await compare(); - const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n'); - // const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n'); - writeFileSync('./migrations.sql', upSql + '\n\n'); - console.log('Wrote migrations.sql'); -}; - -const generate = async (path: string) => { - const { up, down } = await compare(); - if (up.items.length === 0) { - console.log('No changes detected'); - return; - } - create(path, up.asSql(), down.asSql()); -}; - -const create = (path: string, up: string[], down: string[]) => { - const timestamp = Date.now(); - const name = basename(path, extname(path)); - const filename = `${timestamp}-${name}.ts`; - const folder = dirname(path); - const fullPath = join(folder, filename); - mkdirSync(folder, { recursive: true }); - writeFileSync(fullPath, asMigration({ up, down })); - console.log(`Wrote ${fullPath}`); -}; - -const compare = async () => { - const configRepository = new ConfigRepository(); - const { database } = configRepository.getEnv(); - - const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); - const target = await schemaFromDatabase({ connection: database.config }); - - console.log(source.warnings.join('\n')); - - const up = schemaDiff(source, target, { - tables: { ignoreExtra: true }, - functions: { ignoreExtra: false }, - parameters: { ignoreExtra: true }, - }); - const down = schemaDiff(target, source, { - tables: { ignoreExtra: false, ignoreMissing: true }, - functions: { ignoreExtra: false }, - extensions: { ignoreMissing: true }, - parameters: { ignoreMissing: true }, - }); - - return { up, down }; -}; - -type MigrationProps = { - up: string[]; - down: string[]; -}; - -const asMigration = ({ up, down }: MigrationProps) => { - const upSql = up.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n'); - const downSql = down.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n'); - - return `import { Kysely, sql } from 'kysely'; - -export async function up(db: Kysely): Promise { -${upSql} -} - -export async function down(db: Kysely): Promise { -${downSql} -} -`; -}; - -const markMigrationAsReverted = (migrationName: string) => { - // eslint-disable-next-line unicorn/prefer-module - const distRoot = join(__dirname, '..'); - const projectRoot = join(distRoot, '..'); - const sourceFolder = join(projectRoot, 'src', 'schema', 'migrations'); - const distFolder = join(distRoot, 'schema', 'migrations'); - - const sourcePath = join(sourceFolder, `${migrationName}.ts`); - const revertedFolder = join(sourceFolder, 'reverted'); - const revertedPath = join(revertedFolder, `${migrationName}.ts`); - - if (existsSync(revertedPath)) { - console.log(`Migration ${migrationName} is already marked as reverted`); - } else if (existsSync(sourcePath)) { - mkdirSync(revertedFolder, { recursive: true }); - renameSync(sourcePath, revertedPath); - console.log(`Moved ${sourcePath} to ${revertedPath}`); - } else { - console.warn(`Source migration file not found for ${migrationName}`); - } - - const distBase = join(distFolder, migrationName); - for (const extension of ['.js', '.js.map', '.d.ts']) { - const filePath = `${distBase}${extension}`; - if (existsSync(filePath)) { - rmSync(filePath, { force: true }); - console.log(`Removed ${filePath}`); - } - } -}; - -main() - .then(() => { - process.exit(0); - }) - .catch((error) => { - console.error(error); - console.log('Something went wrong'); - process.exit(1); - }); diff --git a/server/src/config.ts b/server/src/config.ts index 2a43b51187..e6134df477 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -206,7 +206,7 @@ export const defaults = Object.freeze({ targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], targetAudioCodec: AudioCodec.Aac, - acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus], + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], targetResolution: '720', maxBitrate: '0', diff --git a/server/src/constants.ts b/server/src/constants.ts index 9ea5e134b6..e24057beba 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -2,7 +2,7 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { SemVer } from 'semver'; -import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; +import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; export const ErrorMessages = { InconsistentMediaLocation: @@ -201,3 +201,11 @@ export const endpointTags: Record = { [ApiTag.Workflows]: 'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.', }; + +export const AUDIO_ENCODER: Record = { + [AudioCodec.Aac]: 'aac', + [AudioCodec.Mp3]: 'mp3', + [AudioCodec.Libopus]: 'libopus', + [AudioCodec.Opus]: 'libopus', + [AudioCodec.PcmS16le]: 'pcm_s16le', +}; diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 2893a27539..69bf1f6443 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -207,12 +207,28 @@ describe(AssetController.name, () => { }); it('should reject invalid rating', async () => { - for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) { + for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest()); } }); + + it('should convert rating 0 to null', async () => { + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 }); + expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null }); + expect(status).toBe(200); + }); + + it('should leave correct ratings as-is', async () => { + const assetId = factory.uuid(); + for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) { + const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test); + expect(service.update).toHaveBeenCalledWith(undefined, assetId, test); + expect(status).toBe(200); + } + }); }); describe('GET /assets/statistics', () => { 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 28f2213169..6baf237b13 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/asset.dto.ts b/server/src/dtos/asset.dto.ts index 00ea46f789..b7bd7a18e8 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsArray, IsDateString, @@ -16,6 +16,7 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; +import { HistoryBuilder, Property } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; @@ -56,12 +57,19 @@ export class UpdateAssetBase { @IsNotEmpty() longitude?: number; - @ApiProperty({ description: 'Rating' }) - @Optional() + @Property({ + description: 'Rating in range [1-5], or null for unrated', + history: new HistoryBuilder() + .added('v1') + .stable('v2') + .updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'), + }) + @Optional({ nullable: true }) @IsInt() @Max(5) @Min(-1) - rating?: number; + @Transform(({ value }) => (value === 0 ? null : value)) + rating?: number | null; @ApiProperty({ description: 'Asset description' }) @Optional() diff --git a/server/src/dtos/bbox.dto.ts b/server/src/dtos/bbox.dto.ts new file mode 100644 index 0000000000..1afe9f53ba --- /dev/null +++ b/server/src/dtos/bbox.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsLatitude, IsLongitude } from 'class-validator'; +import { IsGreaterThanOrEqualTo } from 'src/validation'; + +export class BBoxDto { + @ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' }) + @IsLongitude() + west!: number; + + @ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' }) + @IsLatitude() + south!: number; + + @ApiProperty({ + format: 'double', + description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.', + }) + @IsLongitude() + east!: number; + + @ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' }) + @IsLatitude() + @IsGreaterThanOrEqualTo('south') + north!: number; +} 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/search.dto.ts b/server/src/dtos/search.dto.ts index 47a1889e47..f72ecdf8b6 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { Place } from 'src/database'; -import { HistoryBuilder } from 'src/decorators'; +import { HistoryBuilder, Property } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder, AssetType, AssetVisibility } from 'src/enum'; @@ -103,12 +103,21 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' }) albumIds?: string[]; - @ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 }) - @Optional() + @Property({ + type: 'number', + description: 'Filter by rating [1-5], or null for unrated', + minimum: -1, + maximum: 5, + history: new HistoryBuilder() + .added('v1') + .stable('v2') + .updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'), + }) + @Optional({ nullable: true }) @IsInt() @Max(5) @Min(-1) - rating?: number; + rating?: number | null; @ApiPropertyOptional({ description: 'Filter by OCR text content' }) @IsString() diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 7a0dcb6f3a..a214dbc467 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ArrayMinSize, IsInt, @@ -92,6 +92,16 @@ export class SystemConfigFFmpegDto { targetAudioCodec!: AudioCodec; @ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' }) + @Transform(({ value }) => { + if (Array.isArray(value)) { + const libopusIndex = value.indexOf('libopus'); + if (libopusIndex !== -1) { + value[libopusIndex] = 'opus'; + } + } + + return value; + }) acceptedAudioCodecs!: AudioCodec[]; @ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' }) 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/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index dfd474d885..9ea9dc49ae 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; - import { IsString } from 'class-validator'; +import type { BBoxDto } from 'src/dtos/bbox.dto'; import { AssetOrder, AssetVisibility } from 'src/enum'; +import { ValidateBBox } from 'src/utils/bbox'; import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class TimeBucketDto { @@ -59,6 +60,9 @@ export class TimeBucketDto { description: 'Include location data in the response', }) withCoordinates?: boolean; + + @ValidateBBox({ optional: true }) + bbox?: BBoxDto; } export class TimeBucketAssetDto extends TimeBucketDto { 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 cbc900fbce..d6870372f1 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 { @@ -409,7 +410,9 @@ export enum VideoCodec { export enum AudioCodec { Mp3 = 'mp3', Aac = 'aac', - LibOpus = 'libopus', + /** @deprecated Use `Opus` instead */ + Libopus = 'libopus', + Opus = 'opus', PcmS16le = 'pcm_s16le', } diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 54b3c92dd4..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 @@ -562,6 +580,7 @@ select "asset"."checksum", "asset"."originalPath", "asset"."isExternal", + "asset"."visibility", "asset"."originalFileName", "asset"."livePhotoVideoId", "asset"."fileCreatedAt", @@ -593,6 +612,7 @@ from where "asset"."deletedAt" is null and "asset"."id" = $2 + and "asset"."visibility" != $3 -- AssetJobRepository.streamForStorageTemplateJob select @@ -602,6 +622,7 @@ select "asset"."checksum", "asset"."originalPath", "asset"."isExternal", + "asset"."visibility", "asset"."originalFileName", "asset"."livePhotoVideoId", "asset"."fileCreatedAt", @@ -632,6 +653,7 @@ from inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."deletedAt" is null + and "asset"."visibility" != $2 -- AssetJobRepository.streamForDeletedJob select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4b8323cd59..a2525c3b17 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -123,13 +123,13 @@ with ) as "year" ) select - "a".*, - to_json("asset_exif") as "exifInfo" + "a".* from "today" inner join lateral ( select - "asset".* + "asset"."id", + "asset"."localDateTime" from "asset" inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId" @@ -151,7 +151,6 @@ with limit $7 ) as "a" on true - inner join "asset_exif" on "a"."id" = "asset_exif"."assetId" ) select date_part( @@ -439,6 +438,7 @@ with and "stack"."primaryAssetId" != "asset"."id" ) order by + (asset."localDateTime" AT TIME ZONE 'UTC')::date desc, "asset"."fileCreatedAt" desc ), "agg" as ( @@ -629,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 8540da91c8..2630e384fc 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -102,22 +102,30 @@ order by "shared_link"."createdAt" desc -- SharedLinkRepository.getAll -select distinct - on ("shared_link"."createdAt") "shared_link".*, - "assets"."assets", +select + "shared_link".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset".* + from + "shared_link_asset" + inner join "asset" on "asset"."id" = "shared_link_asset"."assetId" + where + "shared_link"."id" = "shared_link_asset"."sharedLinkId" + and "asset"."deletedAt" is null + order by + "asset"."fileCreatedAt" asc + limit + $1 + ) as agg + ) as "assets", to_json("album") as "album" from "shared_link" - left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id" - left join lateral ( - select - json_agg("asset") as "assets" - from - "asset" - where - "asset"."id" = "shared_link_asset"."assetId" - and "asset"."deletedAt" is null - ) as "assets" on true left join lateral ( select "album".*, @@ -152,12 +160,12 @@ from and "album"."deletedAt" is null ) as "album" on true where - "shared_link"."userId" = $1 + "shared_link"."userId" = $2 and ( - "shared_link"."type" = $2 + "shared_link"."type" = $3 or "album"."id" is not null ) - and "shared_link"."albumId" = $3 + and "shared_link"."albumId" = $4 order by "shared_link"."createdAt" desc @@ -236,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 f4b93a775b..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(); } @@ -353,6 +367,7 @@ export class AssetJobRepository { 'asset.checksum', 'asset.originalPath', 'asset.isExternal', + 'asset.visibility', 'asset.originalFileName', 'asset.livePhotoVideoId', 'asset.fileCreatedAt', @@ -367,13 +382,16 @@ export class AssetJobRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getForStorageTemplateJob(id: string) { - return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst(); + getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) { + return this.storageTemplateAssetQuery() + .where('asset.id', '=', id) + .$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden)) + .executeTakeFirst(); } @GenerateSql({ params: [], stream: true }) streamForStorageTemplateJob() { - return this.storageTemplateAssetQuery().stream(); + return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream(); } @GenerateSql({ params: [DummyValue.DATE], stream: true }) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b58d852707..2e1d02ef28 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,16 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; +import { + ExpressionBuilder, + Insertable, + Kysely, + NotNull, + Selectable, + SelectQueryBuilder, + ShallowDehydrateObject, + sql, + Updateable, + UpdateResult, +} from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; @@ -25,6 +36,7 @@ import { withExif, withFaces, withFacesAndPeople, + withFilePath, withFiles, withLibrary, withOwner, @@ -36,6 +48,13 @@ import { globToSqlPattern } from 'src/utils/misc'; export type AssetStats = Record; +export interface BoundingBox { + west: number; + south: number; + east: number; + north: number; +} + interface AssetStatsOptions { isFavorite?: boolean; isTrashed?: boolean; @@ -64,6 +83,7 @@ interface AssetBuilderOptions { assetType?: AssetType; visibility?: AssetVisibility; withCoordinates?: boolean; + bbox?: BoundingBox; } export interface TimeBucketOptions extends AssetBuilderOptions { @@ -120,6 +140,34 @@ interface GetByIdsRelations { const distinctLocked = (eb: ExpressionBuilder, columns: T) => sql`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`; +const getBoundingCircle = (bbox: BoundingBox) => { + const { west, south, east, north } = bbox; + const eastUnwrapped = west <= east ? east : east + 360; + const centerLongitude = (((west + eastUnwrapped) / 2 + 540) % 360) - 180; + const centerLatitude = (south + north) / 2; + const radius = sql`greatest( + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${west})), + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${east})), + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${west})), + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${east})) + )`; + + return { centerLatitude, centerLongitude, radius }; +}; + +const withBoundingBox = (qb: SelectQueryBuilder, bbox: BoundingBox) => { + const { west, south, east, north } = bbox; + const withLatitude = qb.where('asset_exif.latitude', '>=', south).where('asset_exif.latitude', '<=', north); + + if (west <= east) { + return withLatitude.where('asset_exif.longitude', '>=', west).where('asset_exif.longitude', '<=', east); + } + + return withLatitude.where((eb) => + eb.or([eb('asset_exif.longitude', '>=', west), eb('asset_exif.longitude', '<=', east)]), + ); +}; + @Injectable() export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -358,7 +406,7 @@ export class AssetRepository { (qb) => qb .selectFrom('asset') - .selectAll('asset') + .select(['asset.id', 'asset.localDateTime']) .innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId') .where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`) .where('asset.ownerId', '=', anyUuid(ownerIds)) @@ -377,9 +425,7 @@ export class AssetRepository { .as('a'), (join) => join.onTrue(), ) - .innerJoin('asset_exif', 'a.id', 'asset_exif.assetId') - .selectAll('a') - .select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')), + .selectAll('a'), ) .selectFrom('res') .select(sql`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year')) @@ -510,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) @@ -519,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)) @@ -651,6 +701,20 @@ export class AssetRepository { .select(truncatedDate().as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .$if(!!options.bbox, (qb) => { + const bbox = options.bbox!; + const circle = getBoundingCircle(bbox); + + const withBoundingCircle = qb + .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .where( + sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`, + '@>', + sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`, + ); + + return withBoundingBox(withBoundingCircle, bbox); + }) .$if(options.visibility === undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) .$if(!!options.albumId, (qb) => @@ -686,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 @@ -725,6 +790,18 @@ export class AssetRepository { .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility == undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) + .$if(!!options.bbox, (qb) => { + const bbox = options.bbox!; + const circle = getBoundingCircle(bbox); + + const withBoundingCircle = qb.where( + sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`, + '@>', + sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`, + ); + + return withBoundingBox(withBoundingCircle, bbox); + }) .where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, '')) .$if(!!options.albumId, (qb) => qb.where((eb) => @@ -771,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 @@ -942,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 { @@ -1062,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 06bdef5abf..7ae1119bbc 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -22,6 +22,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode import { DB } from 'src/schema'; +import { immich_uuid_v7 } from 'src/schema/functions'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { vectorIndexQuery } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; @@ -288,7 +289,11 @@ export class DatabaseRepository { } async getSchemaDrift() { - const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); + const source = schemaFromCode({ + overrides: true, + namingStrategy: 'default', + uuidFunction: (version) => (version === 7 ? `${immich_uuid_v7.name}()` : 'uuid_generate_v4()'), + }); const { database } = this.configRepository.getEnv(); const target = await schemaFromDatabase({ connection: database.config }); @@ -426,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/media.repository.ts b/server/src/repositories/media.repository.ts index 7b0b30583d..58e006171a 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -107,7 +107,7 @@ export class MediaRepository { ExposureTime: tags.exposureTime, ProfileDescription: tags.profileDescription, ColorSpace: tags.colorspace, - Rating: tags.rating, + Rating: tags.rating === null ? 0 : tags.rating, // specially convert Orientation to numeric Orientation# for exiftool 'Orientation#': tags.orientation ? Number(tags.orientation) : undefined, }; 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 37a5bca718..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, NotNull, sql, Updateable } from 'kysely'; -import { jsonObjectFrom } from 'kysely/helpers/postgres'; +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)])) @@ -124,19 +129,18 @@ export class SharedLinkRepository { .selectFrom('shared_link') .selectAll('shared_link') .where('shared_link.userId', '=', userId) - .leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id') - .leftJoinLateral( - (eb) => + .select((eb) => + jsonArrayFrom( eb - .selectFrom('asset') - .select((eb) => eb.fn.jsonAgg('asset').as('assets')) - .whereRef('asset.id', '=', 'shared_link_asset.assetId') + .selectFrom('shared_link_asset') + .whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId') + .innerJoin('asset', 'asset.id', 'shared_link_asset.assetId') .where('asset.deletedAt', 'is', null) - .as('assets'), - (join) => join.onTrue(), + .selectAll('asset') + .orderBy('asset.fileCreatedAt', 'asc') + .limit(1), + ).as('assets'), ) - .select('assets.assets') - .$narrowType<{ assets: NotNull }>() .leftJoinLateral( (eb) => eb @@ -174,12 +178,11 @@ 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!)) .orderBy('shared_link.createdAt', 'desc') - .distinctOn(['shared_link.createdAt']) .execute(); } @@ -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/1771535611395-ConvertRating0ToNull.ts b/server/src/schema/migrations/1771535611395-ConvertRating0ToNull.ts new file mode 100644 index 0000000000..8faebb250e --- /dev/null +++ b/server/src/schema/migrations/1771535611395-ConvertRating0ToNull.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = 0;`.execute(db); +} + +export async function down(): Promise { + // not supported +} diff --git a/server/src/schema/migrations/1772121424533-AddAssetExifGistEarthcoord.ts b/server/src/schema/migrations/1772121424533-AddAssetExifGistEarthcoord.ts new file mode 100644 index 0000000000..f86529142d --- /dev/null +++ b/server/src/schema/migrations/1772121424533-AddAssetExifGistEarthcoord.ts @@ -0,0 +1,11 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX "IDX_asset_exif_gist_earthcoord" ON "asset_exif" USING gist (ll_to_earth_public(latitude, longitude));`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_asset_exif_gist_earthcoord', '{"type":"index","name":"IDX_asset_exif_gist_earthcoord","sql":"CREATE INDEX \\"IDX_asset_exif_gist_earthcoord\\" ON \\"asset_exif\\" USING gist (ll_to_earth_public(latitude, longitude));"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_asset_exif_gist_earthcoord";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_asset_exif_gist_earthcoord';`.execute(db); +} diff --git a/server/src/schema/migrations/1772129818245-FixStupidWhiteSpace.ts b/server/src/schema/migrations/1772129818245-FixStupidWhiteSpace.ts new file mode 100644 index 0000000000..7dc2c5c72f --- /dev/null +++ b/server/src/schema/migrations/1772129818245-FixStupidWhiteSpace.ts @@ -0,0 +1,36 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_edit_delete() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + UPDATE asset + SET "isEdited" = false + FROM deleted_edit + WHERE asset.id = deleted_edit."assetId" AND asset."isEdited" + AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id); + RETURN NULL; + END + $$;`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + UPDATE asset + SET "isEdited" = false + FROM deleted_edit + WHERE asset.id = deleted_edit."assetId" AND asset."isEdited" + AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id); + RETURN NULL; + END + $function$ +`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db); +} diff --git a/server/src/schema/migrations/1772609167000-UpdateOpusCodecName.ts b/server/src/schema/migrations/1772609167000-UpdateOpusCodecName.ts new file mode 100644 index 0000000000..9fa5f7d788 --- /dev/null +++ b/server/src/schema/migrations/1772609167000-UpdateOpusCodecName.ts @@ -0,0 +1,65 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,acceptedAudioCodecs}', + ( + SELECT jsonb_agg( + CASE + WHEN elem = 'libopus' THEN 'opus' + ELSE elem + END + ) + FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem + ) + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'libopus'; + `.execute(db); + + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,targetAudioCodec}', + '"opus"'::jsonb + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->>'targetAudioCodec' = 'libopus'; + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,acceptedAudioCodecs}', + ( + SELECT jsonb_agg( + CASE + WHEN elem = 'opus' THEN 'libopus' + ELSE elem + END + ) + FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem + ) + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'opus'; + `.execute(db); + + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,targetAudioCodec}', + '"libopus"'::jsonb + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->>'targetAudioCodec' = 'opus'; + `.execute(db); +} 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-exif.table.ts b/server/src/schema/tables/asset-exif.table.ts index 1ae8f731a9..ae47ecfb10 100644 --- a/server/src/schema/tables/asset-exif.table.ts +++ b/server/src/schema/tables/asset-exif.table.ts @@ -1,9 +1,23 @@ -import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools'; +import { + Column, + ForeignKeyColumn, + Generated, + Index, + Int8, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; import { LockableProperty } from 'src/database'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_exif') +@Index({ + name: 'IDX_asset_exif_gist_earthcoord', + using: 'gist', + expression: 'll_to_earth_public(latitude, longitude)', +}) @UpdatedAtTrigger('asset_exif_updatedAt') export class AssetExifTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) 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 db895f8321..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 } }); @@ -566,8 +568,10 @@ describe(AssetService.name, () => { .file({ type: AssetFileType.Thumbnail }) .file({ type: AssetFileType.Preview }) .file({ type: AssetFileType.FullSize }) + .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 }); @@ -581,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 () => { @@ -589,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 }); @@ -603,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({ @@ -620,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 }); @@ -631,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); }); @@ -737,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' } }, @@ -755,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 f41004dd1c..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) { @@ -516,7 +516,7 @@ export class AssetService extends BaseService { dateTimeOriginal?: string; latitude?: number; longitude?: number; - rating?: number; + rating?: number | null; }) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const writes = _.omitBy( 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 fc825fb273..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([]); @@ -2015,6 +2019,13 @@ describe(MediaService.name, () => { ); }); + it('should not transcode when policy bitrate and bitrate lower than max bitrate', async () => { + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '50M' } }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).not.toHaveBeenCalled(); + }); + it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } }); @@ -2030,19 +2041,18 @@ describe(MediaService.name, () => { ); }); - it('should transcode when max bitrate is not a number', async () => { + it('should not transcode when max bitrate is not a number', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } }); await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.transcode).toHaveBeenCalledWith( - '/original/path.ext', - expect.any(String), - expect.objectContaining({ - inputOptions: expect.any(Array), - outputOptions: expect.any(Array), - twoPass: false, - }), - ); + expect(mocks.media.transcode).not.toHaveBeenCalled(); + }); + + it('should not transcode when max bitrate is 0', async () => { + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '0' } }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not scale resolution if no target resolution', async () => { @@ -2244,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); @@ -2254,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'] }, }); }); @@ -2565,6 +2577,50 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); }); + describe('should skip transcoding for accepted audio codecs with optimal policy if video is fine', () => { + const acceptedCodecs = [ + { codec: 'aac', probeStub: probeStub.audioStreamAac }, + { codec: 'mp3', probeStub: probeStub.audioStreamMp3 }, + { codec: 'opus', probeStub: probeStub.audioStreamOpus }, + ]; + + beforeEach(() => { + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.Hevc, + transcode: TranscodePolicy.Optimal, + targetResolution: '1080p', + }, + }); + }); + + it.each(acceptedCodecs)('should skip $codec', async ({ probeStub }) => { + mocks.media.probe.mockResolvedValue(probeStub); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).not.toHaveBeenCalled(); + }); + }); + + it('should use libopus audio encoder when target audio is opus', async () => { + mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { + targetAudioCodec: AudioCodec.Opus, + transcode: TranscodePolicy.All, + }, + }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.any(String), + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining(['-c:a libopus']), + twoPass: false, + }), + ); + }); + it('should fail if hwaccel is enabled for an unsupported codec', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ @@ -3553,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', () => { @@ -3569,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 3555d7d108..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; } @@ -717,7 +723,8 @@ export class MediaService extends BaseService { const scalingEnabled = ffmpegConfig.targetResolution !== 'original'; const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes; - const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); + const maxBitrate = this.parseBitrateToBps(ffmpegConfig.maxBitrate); + const isLargerThanTargetBitrate = maxBitrate > 0 && stream.bitrate > maxBitrate; const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec); const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p'); @@ -753,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) { @@ -769,6 +784,7 @@ export class MediaService extends BaseService { const bitrateValue = Number.parseInt(bitrateString); if (Number.isNaN(bitrateValue)) { + this.logger.log(`Maximum bitrate '${bitrateString} is not a number and will be ignored.`); return 0; } 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 feaba36b1d..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 }); @@ -1423,9 +1424,23 @@ describe(MetadataService.name, () => { ); }); + it('should handle 0 as unrated -> null', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mockReadTags({ Rating: 0 }); + + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: null, + }), + { lockedPropertiesBehavior: 'skip' }, + ); + }); + 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 }); @@ -1439,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 }); @@ -1454,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 }); @@ -1476,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' }); @@ -1504,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' }); @@ -1519,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' }); @@ -1554,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 }); @@ -1581,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 }); @@ -1595,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 }); @@ -1609,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 }); @@ -1623,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 }); @@ -1740,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', @@ -1759,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, @@ -1780,6 +1803,28 @@ describe(MetadataService.name, () => { 'timeZone', ]); }); + + it('should write rating', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); + asset.exifInfo.rating = 4; + + mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); + 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 = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); + asset.exifInfo.rating = null; + + mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); + 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']); + }); }); describe('firstDateTime', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index c5d7278d56..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, @@ -301,7 +303,7 @@ export class MetadataService extends BaseService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: validateRange(exifTags.Rating, -1, 5), + rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, @@ -447,11 +449,10 @@ 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, + rating: asset.exifInfo.rating ?? 0, tags: asset.exifInfo.tags, timeZone: asset.exifInfo.timeZone, }, @@ -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 09e0c10b80..9d7262246c 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -6,9 +6,12 @@ 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(); +const stillAsset = AssetFactory.from({ livePhotoVideoId: motionAsset.id }).exif().build(); + describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; let mocks: ServiceMocks; @@ -153,10 +156,66 @@ describe(StorageTemplateService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath }); }); + it('should migrate live photo motion video alongside the still image using album in path', async () => { + const motionAsset = AssetFactory.from({ + type: AssetType.Video, + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const stillAsset = AssetFactory.from({ + livePhotoVideoId: motionAsset.id, + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .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 }); + + mocks.user.get.mockResolvedValue(userStub.user1); + + const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`; + const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`; + + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset)); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); + + mocks.move.create.mockResolvedValueOnce({ + id: '123', + entityId: stillAsset.id, + pathType: AssetPathType.Original, + oldPath: stillAsset.originalPath, + newPath: newStillPicturePath, + }); + + mocks.move.create.mockResolvedValueOnce({ + id: '124', + entityId: motionAsset.id, + pathType: AssetPathType.Original, + oldPath: motionAsset.originalPath, + newPath: newMotionPicturePath, + }); + + await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.Success); + + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath }); + }); + 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}}'; @@ -164,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); @@ -204,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}}'; @@ -213,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, @@ -709,12 +770,20 @@ describe(StorageTemplateService.name, () => { }) .exif() .build(); - const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`; - const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; + 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 }); + + const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`; + const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`; mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -735,11 +804,55 @@ describe(StorageTemplateService.name, () => { await sut.handleMigration(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); - expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath }); }); + + it('should use still photo album info when migrating live photo motion video', async () => { + const user = userStub.user1; + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); + const config = structuredClone(defaults); + config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}'; + + sut.onConfigInit({ newConfig: config }); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); + mocks.user.getList.mockResolvedValue([user]); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); + + mocks.move.create.mockResolvedValueOnce({ + id: '123', + entityId: stillAsset.id, + pathType: AssetPathType.Original, + oldPath: stillAsset.originalPath, + newPath: `/data/library/${user.id}/2022/${album.albumName}/${stillAsset.originalFileName}`, + }); + + mocks.move.create.mockResolvedValueOnce({ + id: '124', + entityId: motionAsset.id, + pathType: AssetPathType.Original, + oldPath: motionAsset.originalPath, + newPath: `/data/library/${user.id}/2022/${album.albumName}/${motionAsset.originalFileName}`, + }); + + await sut.handleMigration(); + + expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id); + expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: stillAsset.id, + originalPath: expect.stringContaining(`/${album.albumName}/`), + }); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: motionAsset.id, + originalPath: expect.stringContaining(`/${album.albumName}/`), + }); + }); }); describe('file rename correctness', () => { diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index a8f4e6a185..3d1bc8f835 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -158,12 +158,14 @@ export class StorageTemplateService extends BaseService { // move motion part of live photo if (asset.livePhotoVideoId) { - const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId); + const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, { + includeHidden: true, + }); if (!livePhotoVideo) { return JobStatus.Failed; } const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); - await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); + await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset); } return JobStatus.Success; } @@ -191,10 +193,12 @@ export class StorageTemplateService extends BaseService { // move motion part of live photo if (asset.livePhotoVideoId) { - const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId); + const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, { + includeHidden: true, + }); if (livePhotoVideo) { const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); - await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); + await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset); } } } @@ -214,7 +218,7 @@ export class StorageTemplateService extends BaseService { await this.moveRepository.cleanMoveHistorySingle(assetId); } - async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) { + async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata, stillPhoto?: StorageAsset) { if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) { // External assets are not affected by storage template // TODO: shouldn't this only apply to external assets? @@ -224,7 +228,7 @@ export class StorageTemplateService extends BaseService { return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => { const { id, originalPath, checksum, fileSizeInByte } = asset; const oldPath = originalPath; - const newPath = await this.getTemplatePath(asset, metadata); + const newPath = await this.getTemplatePath(asset, metadata, stillPhoto); if (!fileSizeInByte) { this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`); @@ -255,7 +259,11 @@ export class StorageTemplateService extends BaseService { }); } - private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise { + private async getTemplatePath( + asset: StorageAsset, + metadata: MoveAssetMetadata, + stillPhoto?: StorageAsset, + ): Promise { const { storageLabel, filename } = metadata; try { @@ -296,8 +304,12 @@ export class StorageTemplateService extends BaseService { let albumName = null; let albumStartDate = null; let albumEndDate = null; + const assetForMetadata = stillPhoto || asset; + if (this.template.needsAlbum) { - const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id); + // For motion videos, use the still photo's album information since motion videos + // don't have album metadata attached directly + const albums = await this.albumRepository.getByAssetId(assetForMetadata.ownerId, assetForMetadata.id); const album = albums?.[0]; if (album) { albumName = album.albumName || null; @@ -310,16 +322,18 @@ export class StorageTemplateService extends BaseService { } } + // For motion videos that are part of live photos, use the still photo's date + // to ensure both parts end up in the same folder const storagePath = this.render(this.template.compiled, { - asset, + asset: assetForMetadata, filename: sanitized, extension, albumName, albumStartDate, albumEndDate, - make: asset.make, - model: asset.model, - lensModel: asset.lensModel, + make: assetForMetadata.make, + model: assetForMetadata.model, + lensModel: assetForMetadata.lensModel, }); const fullPath = path.normalize(path.join(rootPath, storagePath)); let destination = `${fullPath}.${extension}`; 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/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 1c93c9d7d3..b346906fc8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -55,7 +55,7 @@ const updatedConfig = Object.freeze({ threads: 0, preset: 'ultrafast', targetAudioCodec: AudioCodec.Aac, - acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus], + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], targetResolution: '720', targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 3301e61318..4f447f6c3d 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -23,6 +23,24 @@ describe(TimelineService.name, () => { userIds: [authStub.admin.user.id], }); }); + + it('should pass bbox options to repository when all bbox fields are provided', async () => { + mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + + await sut.getTimeBuckets(authStub.admin, { + bbox: { + west: -70, + south: -30, + east: 120, + north: 55, + }, + }); + + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ + userIds: [authStub.admin.user.id], + bbox: { west: -70, south: -30, east: 120, north: 55 }, + }); + }); }); describe('getTimeBucket', () => { 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 c5d1476f65..5420e60361 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -25,7 +25,9 @@ export const getAssetFiles = (files: AssetFile[]) => ({ editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), - editedThumbnailFile: 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/bbox.ts b/server/src/utils/bbox.ts new file mode 100644 index 0000000000..ad02e8355e --- /dev/null +++ b/server/src/utils/bbox.ts @@ -0,0 +1,32 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiPropertyOptions } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsNotEmpty, ValidateNested } from 'class-validator'; +import { Property } from 'src/decorators'; +import { BBoxDto } from 'src/dtos/bbox.dto'; +import { Optional } from 'src/validation'; + +type BBoxOptions = { optional?: boolean }; +export const ValidateBBox = (options: BBoxOptions & ApiPropertyOptions = {}) => { + const { optional, ...apiPropertyOptions } = options; + + return applyDecorators( + Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const [west, south, east, north] = value.split(',', 4).map(Number); + return Object.assign(new BBoxDto(), { west, south, east, north }); + }), + Type(() => BBoxDto), + ValidateNested(), + Property({ + type: 'string', + description: 'Bounding box coordinates as west,south,east,north (WGS84)', + example: '11.075683,49.416711,11.117589,49.454875', + ...apiPropertyOptions, + }), + optional ? Optional({}) : IsNotEmpty(), + ); +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 4dd0c9b302..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), @@ -404,6 +405,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) => qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))), ) + .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) .$if(!!options.withExif, withExifInner) .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', '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/src/utils/media.ts b/server/src/utils/media.ts index b2ffb9ac8b..ce185305bd 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,3 +1,4 @@ +import { AUDIO_ENCODER } from 'src/constants'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum'; import { @@ -117,7 +118,7 @@ export class BaseConfig implements VideoCodecSWConfig { getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy'; - const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioCodec() : 'copy'; + const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy'; const options = [ `-c:v ${videoCodec}`, @@ -305,8 +306,8 @@ export class BaseConfig implements VideoCodecSWConfig { return [options]; } - getAudioCodec(): string { - return this.config.targetAudioCodec; + getAudioEncoder(): string { + return AUDIO_ENCODER[this.config.targetAudioCodec]; } getVideoCodec(): string { diff --git a/server/src/validation.ts b/server/src/validation.ts index ce7ceb602f..b959de94b1 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -427,3 +427,25 @@ export function IsIPRange(options: IsIPRangeOptions, validationOptions?: Validat validationOptions, ); } + +@ValidatorConstraint({ name: 'isGreaterThanOrEqualTo' }) +export class IsGreaterThanOrEqualToConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments) { + const relatedPropertyName = args.constraints?.[0] as string; + const relatedValue = (args.object as Record)[relatedPropertyName]; + if (!Number.isFinite(value) || !Number.isFinite(relatedValue)) { + return true; + } + + return Number(value) >= Number(relatedValue); + } + + defaultMessage(args: ValidationArguments) { + const relatedPropertyName = args.constraints?.[0] as string; + return `${args.property} must be greater than or equal to ${relatedPropertyName}`; + } +} + +export const IsGreaterThanOrEqualTo = (property: string, validationOptions?: ValidationOptions) => { + return Validate(IsGreaterThanOrEqualToConstraint, [property], validationOptions); +}; 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/media.stub.ts b/server/test/fixtures/media.stub.ts index 727f5ae7cf..23617fcaf0 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -112,7 +112,7 @@ export const probeStub = { }), videoStream40Mbps: Object.freeze({ ...probeStubDefault, - videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }], + videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000, codecName: 'h264' }], }), videoStreamMTS: Object.freeze({ ...probeStubDefault, @@ -221,6 +221,14 @@ export const probeStub = { ...probeStubDefault, audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }], }), + audioStreamMp3: Object.freeze({ + ...probeStubDefault, + audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }], + }), + audioStreamOpus: Object.freeze({ + ...probeStubDefault, + audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }], + }), audioStreamUnknown: Object.freeze({ ...probeStubDefault, audioStreams: [ 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 eb57c10e2e..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 { @@ -12,6 +20,7 @@ export const getForStorageTemplate = (asset: ReturnType) isExternal: asset.isExternal, checksum: asset.checksum, timeZone: asset.exifInfo.timeZone, + visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt, originalPath: asset.originalPath, originalFileName: asset.originalFileName, @@ -46,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.factory.ts b/server/test/medium.factory.ts index 51dde6b36b..53bf78b5b8 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -3,6 +3,7 @@ import { Insertable, Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { createHash, randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; +import { resolve } from 'node:path'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; @@ -78,6 +79,9 @@ import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; import { automock, wait } from 'test/utils'; import { Mocked } from 'vitest'; +// eslint-disable-next-line unicorn/prefer-module +export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets'); + interface ClassConstructor extends Function { new (...args: any[]): T; } @@ -233,6 +237,14 @@ export class MediumTestContext { return { albumUser: { albumId, userId, role }, result }; } + async softDeleteAsset(assetId: string) { + await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute(); + } + + async softDeleteAlbum(albumId: string) { + await this.database.updateTable('album').set({ deletedAt: new Date() }).where('id', '=', albumId).execute(); + } + async newJobStatus(dto: Partial> & { assetId: string }) { const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId }); const result = await this.get(AssetRepository).upsertJobStatus(jobStatus); diff --git a/server/test/medium/specs/exif/exif-date-time.spec.ts b/server/test/medium/specs/exif/exif-date-time.spec.ts index e46f17855e..3341800b30 100644 --- a/server/test/medium/specs/exif/exif-date-time.spec.ts +++ b/server/test/medium/specs/exif/exif-date-time.spec.ts @@ -2,7 +2,7 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { DB } from 'src/schema'; -import { ExifTestContext } from 'test/medium.factory'; +import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let database: Kysely; @@ -11,7 +11,7 @@ const setup = async (testAssetPath: string) => { const ctx = new ExifTestContext(database); const { user } = await ctx.newUser(); - const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const originalPath = resolve(testAssetsDir, testAssetPath); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); return { ctx, sut: ctx.sut, asset }; diff --git a/server/test/medium/specs/exif/exif-gps.spec.ts b/server/test/medium/specs/exif/exif-gps.spec.ts index 651321b599..91326be28c 100644 --- a/server/test/medium/specs/exif/exif-gps.spec.ts +++ b/server/test/medium/specs/exif/exif-gps.spec.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely'; import { resolve } from 'node:path'; import { DB } from 'src/schema'; -import { ExifTestContext } from 'test/medium.factory'; +import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let database: Kysely; @@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => { const ctx = new ExifTestContext(database); const { user } = await ctx.newUser(); - const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const originalPath = resolve(testAssetsDir, testAssetPath); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); return { ctx, sut: ctx.sut, asset }; diff --git a/server/test/medium/specs/exif/exif-tags.spec.ts b/server/test/medium/specs/exif/exif-tags.spec.ts index 33a81d24b6..c65d4b3f7e 100644 --- a/server/test/medium/specs/exif/exif-tags.spec.ts +++ b/server/test/medium/specs/exif/exif-tags.spec.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely'; import { resolve } from 'node:path'; import { DB } from 'src/schema'; -import { ExifTestContext } from 'test/medium.factory'; +import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let database: Kysely; @@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => { const ctx = new ExifTestContext(database); const { user } = await ctx.newUser(); - const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const originalPath = resolve(testAssetsDir, testAssetPath); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); return { ctx, sut: ctx.sut, asset }; 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/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index f58ffb6a25..c20b64ca7c 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -88,4 +88,24 @@ describe(SearchService.name, () => { expect(result).toEqual({ total: 0 }); }); }); + + describe('withStacked option', () => { + it('should exclude stacked assets when withStacked is false', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]); + + const auth = factory.auth({ user: { id: user.id } }); + + const response = await sut.searchMetadata(auth, { withStacked: false }); + + expect(response.assets.items.length).toBe(1); + expect(response.assets.items[0].id).toBe(unstackedAsset.id); + }); + }); }); diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts index a43d0de9b9..5873d469a5 100644 --- a/server/test/medium/specs/services/shared-link.service.spec.ts +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -95,6 +95,469 @@ describe(SharedLinkService.name, () => { }); }); + describe('getAll', () => { + it('should return all shared links even when they share the same createdAt', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + const sameTimestamp = '2024-01-01T00:00:00.000Z'; + + const link1 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + createdAt: sameTimestamp, + }); + + const link2 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + createdAt: sameTimestamp, + }); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(2); + const ids = result.map((r) => r.id); + expect(ids).toContain(link1.id); + expect(ids).toContain(link2.id); + }); + + it('should return shared links sorted by createdAt in descending order', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const link1 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + createdAt: '2021-01-01T00:00:00.000Z', + }); + + const link2 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + createdAt: '2023-01-01T00:00:00.000Z', + }); + + const link3 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + createdAt: '2022-01-01T00:00:00.000Z', + }); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(3); + expect(result.map((r) => r.id)).toEqual([link2.id, link3.id, link1.id]); + }); + + it('should not return shared links belonging to other users', async () => { + const { sut, ctx } = setup(); + + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const authA = factory.auth({ user: userA }); + const authB = factory.auth({ user: userB }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const linkA = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: userA.id, + allowUpload: false, + type: SharedLinkType.Individual, + }); + + await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: userB.id, + allowUpload: false, + type: SharedLinkType.Individual, + }); + + const resultA = await sut.getAll(authA, {}); + expect(resultA).toHaveLength(1); + expect(resultA[0].id).toBe(linkA.id); + + const resultB = await sut.getAll(authB, {}); + expect(resultB).toHaveLength(1); + expect(resultB[0].id).not.toBe(linkA.id); + }); + + it('should filter by albumId', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const { album: album1 } = await ctx.newAlbum({ ownerId: user.id }); + const { album: album2 } = await ctx.newAlbum({ ownerId: user.id }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const link1 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album1.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album2.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + const result = await sut.getAll(auth, { albumId: album1.id }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(link1.id); + }); + + it('should return album shared links with album data', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(1); + expect(result[0].album).toBeDefined(); + expect(result[0].album!.id).toBe(album.id); + }); + + it('should return multiple album shared links without sql error from json group by', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const { album: album1 } = await ctx.newAlbum({ ownerId: user.id }); + const { album: album2 } = await ctx.newAlbum({ ownerId: user.id }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const link1 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album1.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + const link2 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album2.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(2); + const ids = result.map((r) => r.id); + expect(ids).toContain(link1.id); + expect(ids).toContain(link2.id); + expect(result[0].album).toBeDefined(); + expect(result[1].album).toBeDefined(); + }); + + it('should return mixed album and individual shared links together', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const { album } = await ctx.newAlbum({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const albumLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + const albumLink2 = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + const individualLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(3); + const ids = result.map((r) => r.id); + expect(ids).toContain(albumLink.id); + expect(ids).toContain(albumLink2.id); + expect(ids).toContain(individualLink.id); + }); + + it('should return only the first asset as cover for an individual shared link', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const assets = await Promise.all([ + ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2021-01-01T00:00:00.000Z' }), + ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2023-01-01T00:00:00.000Z' }), + ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2022-01-01T00:00:00.000Z' }), + ]); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: assets.map(({ asset }) => asset.id), + }); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(1); + expect(result[0].assets).toHaveLength(1); + expect(result[0].assets[0].id).toBe(assets[0].asset.id); + }); + }); + + describe('get', () => { + it('should not return trashed assets for an individual shared link', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' }); + + const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' }); + await ctx.softDeleteAsset(trashedAsset.id); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: [visibleAsset.id, trashedAsset.id], + }); + + const result = await sut.get(auth, sharedLink.id); + expect(result).toBeDefined(); + expect(result!.assets).toHaveLength(1); + expect(result!.assets[0].id).toBe(visibleAsset.id); + }); + + it('should return empty assets when all individually shared assets are trashed', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + await ctx.softDeleteAsset(asset.id); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + + await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({ + assets: [], + }); + }); + + it('should not return trashed assets in a shared album', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: visibleAsset.id }); + + const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: trashedAsset.id }); + await ctx.softDeleteAsset(trashedAsset.id); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: true, + type: SharedLinkType.Album, + }); + + await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({ + album: expect.objectContaining({ assetCount: 1 }), + }); + }); + + it('should return an empty asset count when all album assets are trashed', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + await ctx.softDeleteAsset(asset.id); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({ + album: expect.objectContaining({ assetCount: 0 }), + }); + }); + + it('should not return an album shared link when the album is trashed', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + await ctx.softDeleteAlbum(album.id); + + await expect(sut.get(auth, sharedLink.id)).rejects.toThrow('Shared link not found'); + }); + }); + + describe('getAll', () => { + it('should not return trashed assets as cover for an individual shared link', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const { asset: trashedAsset } = await ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: '2020-01-01T00:00:00.000Z', + }); + await ctx.softDeleteAsset(trashedAsset.id); + + const { asset: visibleAsset } = await ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: '2021-01-01T00:00:00.000Z', + }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: [trashedAsset.id, visibleAsset.id], + }); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(1); + expect(result[0].assets).toHaveLength(1); + expect(result[0].assets[0].id).toBe(visibleAsset.id); + }); + + it('should not return an album shared link when the album is trashed', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: false, + type: SharedLinkType.Album, + }); + + await ctx.softDeleteAlbum(album.id); + + const result = await sut.getAll(auth, {}); + expect(result).toHaveLength(0); + }); + }); + it('should remove individually shared asset', async () => { const { sut, ctx } = 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/server/test/vitest.config.medium.mjs b/server/test/vitest.config.medium.mjs index fe6a93accb..4c3647f1df 100644 --- a/server/test/vitest.config.medium.mjs +++ b/server/test/vitest.config.medium.mjs @@ -1,10 +1,15 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; +const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + export default defineConfig({ test: { - root: './', + name: 'server:medium', + root: serverRoot, globals: true, include: ['test/medium/**/*.spec.ts'], globalSetup: ['test/medium/globalSetup.ts'], diff --git a/server/test/vitest.config.mjs b/server/test/vitest.config.mjs index 79d053d176..1cecd62e9f 100644 --- a/server/test/vitest.config.mjs +++ b/server/test/vitest.config.mjs @@ -1,10 +1,15 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; +const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + export default defineConfig({ test: { - root: './', + name: 'server:unit', + root: serverRoot, globals: true, include: ['src/**/*.spec.ts'], coverage: { diff --git a/web/package.json b/web/package.json index 03ccb35d7e..9c63b2e5f5 100644 --- a/web/package.json +++ b/web/package.json @@ -16,8 +16,8 @@ "check:all": "pnpm run check:code && pnpm run test:cov", "lint": "eslint . --max-warnings 0 --concurrency 4", "lint:fix": "pnpm run lint --fix", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "test": "vitest", "test:cov": "vitest --coverage", "test:watch": "vitest dev", @@ -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" @@ -83,7 +85,7 @@ "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", - "@vitest/coverage-v8": "^3.0.0", + "@vitest/coverage-v8": "^4.0.0", "dotenv": "^17.0.0", "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", @@ -98,14 +100,14 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.53.0", + "svelte": "5.53.7", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", "typescript": "^5.8.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.2", - "vitest": "^3.0.0" + "vitest": "^4.0.0" }, "volta": { "node": "24.13.1" diff --git a/web/src/app.css b/web/src/app.css index 3a4d29b466..1ff3bec99b 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -49,6 +49,7 @@ } @theme { + --font-sans: 'GoogleSans', sans-serif; --font-mono: 'GoogleSansCode', monospace; --spacing-18: 4.5rem; @@ -100,7 +101,7 @@ } :root { - font-family: 'GoogleSans', sans-serif; + font-family: var(--font-sans); letter-spacing: 0.1px; /* Used by layouts to ensure proper spacing between navbar and content */ diff --git a/web/src/lib/__mocks__/animate.mock.ts b/web/src/lib/__mocks__/animate.mock.ts index 5f0d367d86..76ac1318b7 100644 --- a/web/src/lib/__mocks__/animate.mock.ts +++ b/web/src/lib/__mocks__/animate.mock.ts @@ -2,7 +2,7 @@ import { tick } from 'svelte'; import { vi } from 'vitest'; export const getAnimateMock = () => - vi.fn().mockImplementation(() => { + vi.fn().mockImplementation(function () { let onfinish: (() => void) | null = null; void tick().then(() => onfinish?.()); diff --git a/web/src/lib/__mocks__/intersection-observer.mock.ts b/web/src/lib/__mocks__/intersection-observer.mock.ts index 5565e9a139..9f3dc05dce 100644 --- a/web/src/lib/__mocks__/intersection-observer.mock.ts +++ b/web/src/lib/__mocks__/intersection-observer.mock.ts @@ -1,9 +1,11 @@ import { vi } from 'vitest'; export const getIntersectionObserverMock = () => - vi.fn(() => ({ - disconnect: vi.fn(), - observe: vi.fn(), - takeRecords: vi.fn(), - unobserve: vi.fn(), - })); + vi.fn(function () { + return { + disconnect: vi.fn(), + observe: vi.fn(), + takeRecords: vi.fn(), + unobserve: vi.fn(), + }; + }); diff --git a/web/src/lib/__mocks__/resize-observer.mock.ts b/web/src/lib/__mocks__/resize-observer.mock.ts index ffd1dad2fd..da4baef5ba 100644 --- a/web/src/lib/__mocks__/resize-observer.mock.ts +++ b/web/src/lib/__mocks__/resize-observer.mock.ts @@ -1,8 +1,10 @@ import { vi } from 'vitest'; export const getResizeObserverMock = () => - vi.fn(() => ({ - disconnect: vi.fn(), - observe: vi.fn(), - unobserve: vi.fn(), - })); + vi.fn(function () { + return { + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), + }; + }); 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/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts deleted file mode 100644 index 74643aa95d..0000000000 --- a/web/src/lib/actions/intersection-observer.ts +++ /dev/null @@ -1,156 +0,0 @@ -type Config = IntersectionObserverActionProperties & { - observer?: IntersectionObserver; -}; -type TrackedProperties = { - root?: Element | Document | null; - threshold?: number | number[]; - top?: string; - right?: string; - bottom?: string; - left?: string; -}; -type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown; -type OnSeparateCallback = (element: HTMLElement) => unknown; -type IntersectionObserverActionProperties = { - key?: string; - disabled?: boolean; - /** Function to execute when the element leaves the viewport */ - onSeparate?: OnSeparateCallback; - /** Function to execute when the element enters the viewport */ - onIntersect?: OnIntersectCallback; - - root?: Element | Document | null; - threshold?: number | number[]; - top?: string; - right?: string; - bottom?: string; - left?: string; -}; -type TaskKey = HTMLElement | string; - -function isEquivalent(a: TrackedProperties, b: TrackedProperties) { - return ( - a?.bottom === b?.bottom && - a?.top === b?.top && - a?.left === b?.left && - a?.right == b?.right && - a?.threshold === b?.threshold && - a?.root === b?.root - ); -} - -const elementToConfig = new Map(); - -const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => { - if (!target.isConnected) { - elementToConfig.get(key)?.observer?.unobserve(target); - return; - } - const { - root, - threshold, - top = '0px', - right = '0px', - bottom = '0px', - left = '0px', - onSeparate, - onIntersect, - } = properties; - const rootMargin = `${top} ${right} ${bottom} ${left}`; - const observer = new IntersectionObserver( - (entries: IntersectionObserverEntry[]) => { - // This IntersectionObserver is limited to observing a single element, the one the - // action is attached to. If there are multiple entries, it means that this - // observer is being notified of multiple events that have occurred quickly together, - // and the latest element is the one we are interested in. - - entries.sort((a, b) => a.time - b.time); - - const latestEntry = entries.pop(); - if (latestEntry?.isIntersecting) { - onIntersect?.(latestEntry); - } else { - onSeparate?.(target); - } - }, - { - rootMargin, - threshold, - root, - }, - ); - observer.observe(target); - elementToConfig.set(key, { ...properties, observer }); -}; - -function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) { - if (properties.disabled) { - const config = elementToConfig.get(key); - const { observer } = config || {}; - observer?.unobserve(element); - elementToConfig.delete(key); - } else { - elementToConfig.set(key, properties); - observe(key, element, properties); - } -} - -function _intersectionObserver( - key: HTMLElement | string, - element: HTMLElement, - properties: IntersectionObserverActionProperties, -) { - configure(key, element, properties); - return { - update(properties: IntersectionObserverActionProperties) { - const config = elementToConfig.get(key); - if (!config) { - return; - } - if (isEquivalent(config, properties)) { - return; - } - - configure(key, element, properties); - }, - destroy: () => { - const config = elementToConfig.get(key); - const { observer } = config || {}; - observer?.unobserve(element); - elementToConfig.delete(key); - }, - }; -} - -/** - * Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold). - * @param element - * @param properties One or multiple configurations for the IntersectionObserver(s) - * @returns - */ -export function intersectionObserver( - element: HTMLElement, - properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[], -) { - // svelte doesn't allow multiple use:action directives of the same kind on the same element, - // so accept an array when multiple configurations are needed. - if (Array.isArray(properties)) { - if (!properties.every((p) => p.key)) { - throw new Error('Multiple configurations must specify key'); - } - const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p)); - return { - update: (properties: IntersectionObserverActionProperties[]) => { - for (const [i, props] of properties.entries()) { - observers[i].update(props); - } - }, - destroy: () => { - for (const observer of observers) { - observer.destroy(); - } - }, - }; - } - return _intersectionObserver(properties.key || element, element, properties); -} diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts deleted file mode 100644 index 4fa35c7d93..0000000000 --- a/web/src/lib/actions/resize-observer.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; - -let observer: ResizeObserver; -let callbacks: WeakMap; - -/** - * Installs a resizeObserver on the given element - when the element changes - * size, invokes a callback function with the width/height. Intended as a - * replacement for bind:clientWidth and bind:clientHeight in svelte4 which use - * an iframe to measure the size of the element, which can be bad for - * performance and memory usage. In svelte5, they adapted bind:clientHeight and - * bind:clientWidth to use an internal resize observer. - * - * TODO: When svelte5 is ready, go back to bind:clientWidth and - * bind:clientHeight. - */ -export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) { - if (!observer) { - callbacks = new WeakMap(); - observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const onResize = callbacks.get(entry.target as HTMLElement); - if (onResize) { - onResize({ - target: entry.target as HTMLElement, - width: entry.borderBoxSize[0].inlineSize, - height: entry.borderBoxSize[0].blockSize, - }); - } - } - }); - } - - callbacks.set(element, onResize); - observer.observe(element); - - return { - destroy: () => { - callbacks.delete(element); - observer.unobserve(element); - }, - }; -} 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/assets/apple/apple-splash-1125-2436.png b/web/src/lib/assets/apple/apple-splash-1125-2436.png deleted file mode 100644 index 0b48eb9259..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1125-2436.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1136-640.png b/web/src/lib/assets/apple/apple-splash-1136-640.png deleted file mode 100644 index 5fa6b3f63b..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1136-640.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1170-2532.png b/web/src/lib/assets/apple/apple-splash-1170-2532.png deleted file mode 100644 index f2fa5ffb55..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1170-2532.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1179-2556.png b/web/src/lib/assets/apple/apple-splash-1179-2556.png deleted file mode 100644 index 633b63a792..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1179-2556.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1242-2208.png b/web/src/lib/assets/apple/apple-splash-1242-2208.png deleted file mode 100644 index f57719892e..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1242-2208.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1242-2688.png b/web/src/lib/assets/apple/apple-splash-1242-2688.png deleted file mode 100644 index 308393c571..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1242-2688.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1284-2778.png b/web/src/lib/assets/apple/apple-splash-1284-2778.png deleted file mode 100644 index 7471ab1594..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1284-2778.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1290-2796.png b/web/src/lib/assets/apple/apple-splash-1290-2796.png deleted file mode 100644 index 74041cefdb..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1290-2796.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1334-750.png b/web/src/lib/assets/apple/apple-splash-1334-750.png deleted file mode 100644 index b7d23946f2..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1334-750.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1536-2048.png b/web/src/lib/assets/apple/apple-splash-1536-2048.png deleted file mode 100644 index 96572dbc98..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1536-2048.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1620-2160.png b/web/src/lib/assets/apple/apple-splash-1620-2160.png deleted file mode 100644 index 23b4f0b185..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1620-2160.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1668-2224.png b/web/src/lib/assets/apple/apple-splash-1668-2224.png deleted file mode 100644 index 4ece3a1c39..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1668-2224.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1668-2388.png b/web/src/lib/assets/apple/apple-splash-1668-2388.png deleted file mode 100644 index 7486415097..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1668-2388.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-1792-828.png b/web/src/lib/assets/apple/apple-splash-1792-828.png deleted file mode 100644 index aaa9064a06..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-1792-828.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2048-1536.png b/web/src/lib/assets/apple/apple-splash-2048-1536.png deleted file mode 100644 index a0e0a35179..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2048-1536.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2048-2732.png b/web/src/lib/assets/apple/apple-splash-2048-2732.png deleted file mode 100644 index 7f807caf0e..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2048-2732.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2160-1620.png b/web/src/lib/assets/apple/apple-splash-2160-1620.png deleted file mode 100644 index 498668ae5e..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2160-1620.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2208-1242.png b/web/src/lib/assets/apple/apple-splash-2208-1242.png deleted file mode 100644 index 4e37708249..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2208-1242.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2224-1668.png b/web/src/lib/assets/apple/apple-splash-2224-1668.png deleted file mode 100644 index 9cd0b7e970..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2224-1668.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2388-1668.png b/web/src/lib/assets/apple/apple-splash-2388-1668.png deleted file mode 100644 index 458f9a2f1f..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2388-1668.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2436-1125.png b/web/src/lib/assets/apple/apple-splash-2436-1125.png deleted file mode 100644 index b0533892bc..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2436-1125.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2532-1170.png b/web/src/lib/assets/apple/apple-splash-2532-1170.png deleted file mode 100644 index 96007d8413..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2532-1170.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2556-1179.png b/web/src/lib/assets/apple/apple-splash-2556-1179.png deleted file mode 100644 index eb99264527..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2556-1179.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2688-1242.png b/web/src/lib/assets/apple/apple-splash-2688-1242.png deleted file mode 100644 index 9631f79452..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2688-1242.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2732-2048.png b/web/src/lib/assets/apple/apple-splash-2732-2048.png deleted file mode 100644 index 61ef4284a1..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2732-2048.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2778-1284.png b/web/src/lib/assets/apple/apple-splash-2778-1284.png deleted file mode 100644 index f8e363ab75..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2778-1284.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-2796-1290.png b/web/src/lib/assets/apple/apple-splash-2796-1290.png deleted file mode 100644 index b229e21bd6..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-2796-1290.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-640-1136.png b/web/src/lib/assets/apple/apple-splash-640-1136.png deleted file mode 100644 index c2cb5083fb..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-640-1136.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-750-1334.png b/web/src/lib/assets/apple/apple-splash-750-1334.png deleted file mode 100644 index ae41d4aa01..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-750-1334.png and /dev/null differ diff --git a/web/src/lib/assets/apple/apple-splash-828-1792.png b/web/src/lib/assets/apple/apple-splash-828-1792.png deleted file mode 100644 index efa06a230c..0000000000 Binary files a/web/src/lib/assets/apple/apple-splash-828-1792.png and /dev/null differ 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 801a466ca8..7ad6dc3ab7 100644 --- a/web/src/lib/components/Image.svelte +++ b/web/src/lib/components/Image.svelte @@ -1,5 +1,6 @@ + +{#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 83596069f9..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} @@ -115,7 +109,7 @@ options={[ { value: AudioCodec.Aac, text: 'AAC' }, { value: AudioCodec.Mp3, text: 'MP3' }, - { value: AudioCodec.Libopus, text: 'Opus' }, + { value: AudioCodec.Opus, text: 'Opus' }, { value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' }, ]} isEdited={!isEqual( @@ -174,7 +168,7 @@ options={[ { value: AudioCodec.Aac, text: 'aac' }, { value: AudioCodec.Mp3, text: 'mp3' }, - { value: AudioCodec.Libopus, text: 'opus' }, + { value: AudioCodec.Opus, text: 'opus' }, ]} name="acodec" isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec} diff --git a/web/src/lib/components/admin-settings/JobSettings.svelte b/web/src/lib/components/admin-settings/JobSettings.svelte index 94b4426dbb..18bc9e7a4c 100644 --- a/web/src/lib/components/admin-settings/JobSettings.svelte +++ b/web/src/lib/components/admin-settings/JobSettings.svelte @@ -4,7 +4,6 @@ import { SettingInputFieldType } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; - import { getQueueName } from '$lib/utils'; import { QueueName, type SystemConfigJobDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -30,6 +29,27 @@ function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto { return jobName in configToEdit.job; } + + const queueTitles: Record = $derived({ + [QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'), + [QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'), + [QueueName.Sidecar]: $t('admin.sidecar_job'), + [QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'), + [QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'), + [QueueName.FaceDetection]: $t('admin.face_detection'), + [QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'), + [QueueName.VideoConversion]: $t('admin.video_conversion_job'), + [QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'), + [QueueName.Migration]: $t('admin.migration_job'), + [QueueName.BackgroundTask]: $t('admin.background_task_job'), + [QueueName.Search]: $t('search'), + [QueueName.Library]: $t('external_libraries'), + [QueueName.Notifications]: $t('notifications'), + [QueueName.BackupDatabase]: $t('admin.backup_database'), + [QueueName.Ocr]: $t('admin.machine_learning_ocr'), + [QueueName.Workflow]: $t('workflows'), + [QueueName.Editor]: $t('editor'), + });
@@ -41,7 +61,7 @@ {#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/admin-settings/SupportedDatetimePanel.svelte b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte index de455380a9..c22d3cb792 100644 --- a/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte +++ b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte @@ -11,85 +11,38 @@ let { options }: Props = $props(); - const getLuxonExample = (format: string) => { - return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format); - }; + const getExampleDate = () => DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }); +{#snippet example(title: string, options: Array)} +
+ {title} +
    + {#each options as format, index (index)} +
  • {`{{${format}}} - ${getExampleDate().toFormat(format)}`}
  • + {/each} +
+
+{/snippet} + {$t('date_and_time')} - {$t('admin.storage_template_date_time_description')} {$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })}{$t('admin.storage_template_date_time_sample', { values: { date: getExampleDate().toISO() } })}
-
- {$t('year')} -
    - {#each options.yearOptions as yearFormat, index (index)} -
  • {'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}
  • - {/each} -
-
- -
- {$t('month')} -
    - {#each options.monthOptions as monthFormat, index (index)} -
  • {'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}
  • - {/each} -
-
- -
- {$t('week')} -
    - {#each options.weekOptions as weekFormat, index (index)} -
  • {'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}
  • - {/each} -
-
- -
- {$t('day')} -
    - {#each options.dayOptions as dayFormat, index (index)} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
- -
- {$t('hour')} -
    - {#each options.hourOptions as dayFormat, index (index)} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
- -
- {$t('minute')} -
    - {#each options.minuteOptions as dayFormat, index (index)} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
- -
- {$t('second')} -
    - {#each options.secondOptions as dayFormat, index (index)} -
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • - {/each} -
-
+ {@render example($t('year'), options.yearOptions)} + {@render example($t('month'), options.monthOptions)} + {@render example($t('week'), options.weekOptions)} + {@render example($t('day'), options.dayOptions)} + {@render example($t('hour'), options.hourOptions)} + {@render example($t('minute'), options.minuteOptions)} + {@render example($t('second'), options.secondOptions)}
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/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte index 00744832a7..481e110fb0 100644 --- a/web/src/lib/components/album-page/album-description.svelte +++ b/web/src/lib/components/album-page/album-description.svelte @@ -1,5 +1,6 @@ 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/actions/rating-action.svelte b/web/src/lib/components/asset-viewer/actions/rating-action.svelte index 3791fccf23..c5b0197121 100644 --- a/web/src/lib/components/asset-viewer/actions/rating-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/rating-action.svelte @@ -17,10 +17,9 @@ const rateAsset = async (rating: number | null) => { try { - const updateAssetDto = rating === null ? {} : { rating }; await updateAsset({ id: asset.id, - updateAssetDto, + updateAssetDto: { rating }, }); asset = { diff --git a/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte b/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte index 22ca22b0d9..98d01ff9d3 100644 --- a/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte @@ -1,5 +1,6 @@ - - + + @@ -484,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')} />
@@ -586,7 +585,7 @@ > {#if showDetailPanel}
- +
{:else if assetViewerManager.isShowEditor}
diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte index 15bf1f21d6..81e7d4e1fb 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -1,5 +1,5 @@ {#if !authManager.isSharedLink && $preferences?.ratings.enabled} -
+
handlePromiseError(handleChangeRating(rating))} />
{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 6e934874e9..e80d376f57 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -17,9 +17,16 @@ import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { delay, getDimensions } from '$lib/utils/asset-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; + import { handleError } from '$lib/utils/handle-error'; import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { getParentPath } from '$lib/utils/tree-utils'; - import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; + import { + AssetMediaSize, + getAllAlbums, + getAssetInfo, + type AlbumResponseDto, + type AssetResponseDto, + } from '@immich/sdk'; import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui'; import { mdiCalendar, @@ -38,16 +45,16 @@ import { slide } from 'svelte/transition'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import PersonSidePanel from '../faces-page/person-side-panel.svelte'; + import OnEvents from '../OnEvents.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte'; interface Props { asset: AssetResponseDto; - albums?: AlbumResponseDto[]; currentAlbum?: AlbumResponseDto | null; } - let { asset, albums = [], currentAlbum = null }: Props = $props(); + let { asset, currentAlbum = null }: Props = $props(); let showAssetPath = $state(false); let showEditFaces = $state(false); @@ -74,14 +81,33 @@ let previousId: string | undefined = $state(); let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos()); + const refreshAlbums = async () => { + if (authManager.isSharedLink) { + return []; + } + + try { + return await getAllAlbums({ assetId: asset.id }); + } catch (error) { + handleError(error, 'Error getting asset album membership'); + return []; + } + }; + + let albums = $derived(refreshAlbums()); + $effect(() => { if (!previousId) { previousId = asset.id; + return; } - if (asset.id !== previousId) { - showEditFaces = false; - previousId = asset.id; + + if (asset.id === previousId) { + return; } + + showEditFaces = false; + previousId = asset.id; }); const getMegapixel = (width: number, height: number): number | undefined => { @@ -119,6 +145,8 @@ }; + (albums = refreshAlbums())} /> +
{/if} -{#if albums.length > 0} -
-
- {$t('appears_in')} -
- {#each albums as album (album.id)} - -
+ {/if} +{/await} {#if $preferences?.tags?.enabled}
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 009d9b29b8..0000000000 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts +++ /dev/null @@ -1,39 +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(''); - }); -}); 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/editor/transform-tool/transform-tool.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte index ed9f854bb9..a45cb5d998 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte @@ -134,7 +134,7 @@ >
{/if} - {ratio.label} + {ratio.label} {/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 01b2982efb..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,10 +3,12 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/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'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; + import { clamp } from 'lodash-es'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -23,10 +25,12 @@ let canvas: Canvas | undefined = $state(); let faceRect: Rect | undefined = $state(); let faceSelectorEl: HTMLDivElement | undefined = $state(); + let scrollableListEl: HTMLDivElement | undefined = $state(); let page = $state(1); let candidates = $state([]); let searchTerm = $state(''); + let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 }); let filteredCandidates = $derived( searchTerm @@ -70,6 +74,7 @@ canvas.add(faceRect); canvas.setActiveObject(faceRect); + setDefaultFaceRectanglePosition(faceRect); }; onMount(async () => { @@ -77,20 +82,31 @@ await getPeople(); }); + 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(() => { - const { actualWidth, actualHeight } = getContainedSize(htmlElement); - const offsetArea = { - width: (containerWidth - actualWidth) / 2, - height: (containerHeight - actualHeight) / 2, - }; - - const imageBoundingBox = { - top: offsetArea.height, - left: offsetArea.width, - width: containerWidth - offsetArea.width * 2, - height: containerHeight - offsetArea.height * 2, - }; - if (!canvas) { return; } @@ -104,39 +120,19 @@ return; } - faceRect.set({ - top: imageBoundingBox.top + 200, - left: imageBoundingBox.left + 200, - }); - - faceRect.setCoords(); - positionFaceSelector(); + if (!isFaceRectIntersectingCanvas(faceRect, canvas)) { + setDefaultFaceRectanglePosition(faceRect); + } }); - const getContainedSize = ( - img: HTMLImageElement | HTMLVideoElement, - ): { actualWidth: number; actualHeight: number } => { - if (img instanceof HTMLImageElement) { - const ratio = img.naturalWidth / img.naturalHeight; - let actualWidth = img.height * ratio; - let actualHeight = img.height; - if (actualWidth > img.width) { - actualWidth = img.width; - actualHeight = img.width / ratio; - } - return { actualWidth, actualHeight }; - } else if (img instanceof HTMLVideoElement) { - const ratio = img.videoWidth / img.videoHeight; - let actualWidth = img.clientHeight * ratio; - let actualHeight = img.clientHeight; - if (actualWidth > img.clientWidth) { - actualWidth = img.clientWidth; - actualHeight = img.clientWidth / ratio; - } - return { actualWidth, actualHeight }; - } - - return { actualWidth: 0, actualHeight: 0 }; + 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 = () => { @@ -157,69 +153,80 @@ } }; + const MAX_LIST_HEIGHT = 250; + const positionFaceSelector = () => { - if (!faceRect || !faceSelectorEl) { + if (!faceRect || !faceSelectorEl || !scrollableListEl) { return; } - const rect = faceRect.getBoundingRect(); + const gap = 15; + const padding = faceRect.padding ?? 0; + const rawBox = faceRect.getBoundingRect(); + const faceBox = { + left: rawBox.left - padding, + top: rawBox.top - padding, + width: rawBox.width + padding * 2, + height: rawBox.height + padding * 2, + }; const selectorWidth = faceSelectorEl.offsetWidth; - const selectorHeight = faceSelectorEl.offsetHeight; + const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight; + const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight); + const selectorHeight = listHeight + chromeHeight; - const spaceAbove = rect.top; - const spaceBelow = containerHeight - (rect.top + rect.height); - const spaceLeft = rect.left; - const spaceRight = containerWidth - (rect.left + rect.width); + const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap); + const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap); - let top, left; + const overlapArea = (position: { top: number; left: number }) => { + const selectorRight = position.left + selectorWidth; + const selectorBottom = position.top + selectorHeight; + const faceRight = faceBox.left + faceBox.width; + const faceBottom = faceBox.top + faceBox.height; - if ( - spaceBelow >= selectorHeight || - (spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight) - ) { - top = rect.top + rect.height + 15; - left = rect.left; - } else if ( - spaceAbove >= selectorHeight || - (spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight) - ) { - top = rect.top - selectorHeight - 15; - left = rect.left; - } else if ( - spaceRight >= selectorWidth || - (spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow) - ) { - top = rect.top; - left = rect.left + rect.width + 15; - } else { - top = rect.top; - left = rect.left - selectorWidth - 15; + const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left)); + const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top)); + return overlapX * overlapY; + }; + + const faceBottom = faceBox.top + faceBox.height; + const faceRight = faceBox.left + faceBox.width; + + const positions = [ + { top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) }, + { top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) }, + { top: clampTop(faceBox.top), left: clampLeft(faceRight + gap) }, + { top: clampTop(faceBox.top), left: clampLeft(faceBox.left - selectorWidth - gap) }, + ]; + + let bestPosition = positions[0]; + let leastOverlap = Infinity; + + for (const position of positions) { + const overlap = overlapArea(position); + if (overlap < leastOverlap) { + leastOverlap = overlap; + bestPosition = position; + if (overlap === 0) { + break; + } + } } - if (left + selectorWidth > containerWidth) { - left = containerWidth - selectorWidth - 15; - } - - if (left < 0) { - left = 15; - } - - if (top + selectorHeight > containerHeight) { - top = containerHeight - selectorHeight - 15; - } - - if (top < 0) { - top = 15; - } - - faceSelectorEl.style.top = `${top}px`; - faceSelectorEl.style.left = `${left}px`; + faceSelectorEl.style.top = `${bestPosition.top}px`; + faceSelectorEl.style.left = `${bestPosition.left}px`; + scrollableListEl.style.height = `${listHeight}px`; + faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height }; }; $effect(() => { - if (faceRect) { - faceRect.on('moving', positionFaceSelector); - faceRect.on('scaling', positionFaceSelector); + const rect = faceRect; + if (rect) { + rect.on('moving', positionFaceSelector); + rect.on('scaling', positionFaceSelector); + return () => { + rect.off('moving', positionFaceSelector); + rect.off('scaling', positionFaceSelector); + }; } }); @@ -229,48 +236,22 @@ } const { left, top, width, height } = faceRect.getBoundingRect(); - const { actualWidth, actualHeight } = getContainedSize(htmlElement); + const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; + const natural = getNaturalSize(htmlElement); - const offsetArea = { - width: (containerWidth - actualWidth) / 2, - height: (containerHeight - actualHeight) / 2, + const scaleX = natural.width / contentWidth; + const scaleY = natural.height / contentHeight; + const imageX = (left - offsetX) * scaleX; + const imageY = (top - offsetY) * scaleY; + + return { + imageWidth: natural.width, + imageHeight: natural.height, + x: Math.floor(imageX), + y: Math.floor(imageY), + width: Math.floor(width * scaleX), + height: Math.floor(height * scaleY), }; - - const x1Coeff = (left - offsetArea.width) / actualWidth; - const y1Coeff = (top - offsetArea.height) / actualHeight; - const x2Coeff = (left + width - offsetArea.width) / actualWidth; - const y2Coeff = (top + height - offsetArea.height) / actualHeight; - - // transpose to the natural image location - if (htmlElement instanceof HTMLImageElement) { - const x1 = x1Coeff * htmlElement.naturalWidth; - const y1 = y1Coeff * htmlElement.naturalHeight; - const x2 = x2Coeff * htmlElement.naturalWidth; - const y2 = y2Coeff * htmlElement.naturalHeight; - - return { - imageWidth: htmlElement.naturalWidth, - imageHeight: htmlElement.naturalHeight, - x: Math.floor(x1), - y: Math.floor(y1), - width: Math.floor(x2 - x1), - height: Math.floor(y2 - y1), - }; - } else if (htmlElement instanceof HTMLVideoElement) { - const x1 = x1Coeff * htmlElement.videoWidth; - const y1 = y1Coeff * htmlElement.videoHeight; - const x2 = x2Coeff * htmlElement.videoWidth; - const y2 = y2Coeff * htmlElement.videoHeight; - - return { - imageWidth: htmlElement.videoWidth, - imageHeight: htmlElement.videoHeight, - x: Math.floor(x1), - y: Math.floor(y1), - width: Math.floor(x2 - x1), - height: Math.floor(y2 - y1), - }; - } }; const tagFace = async (person: PersonResponseDto) => { @@ -308,13 +289,20 @@ }; -
+

{$t('select_person_to_tag')}

@@ -322,7 +310,7 @@
-
+
{#if filteredCandidates.length > 0}
{#each filteredCandidates as person (person.id)} 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-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index f4ba6868e0..926383d9c2 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -5,7 +5,7 @@ import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; - import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils'; + import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils'; import { EquirectangularAdapter, Viewer, @@ -127,9 +127,11 @@ markersPlugin.clearMarkers(); } - const boxes = getOcrBoundingBoxesAtSize(ocrData, { - width: viewer.state.textureData.panoData.croppedWidth, - height: viewer.state.textureData.panoData.croppedHeight, + const boxes = getOcrBoundingBoxes(ocrData, { + contentWidth: viewer.state.textureData.panoData.croppedWidth, + contentHeight: viewer.state.textureData.panoData.croppedHeight, + offsetX: 0, + offsetY: 0, }); for (const [index, box] of boxes.entries()) { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 61181acbc8..4a6a02cb4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,64 +1,56 @@ @@ -192,74 +205,67 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - + - - diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte index 0063ca404e..21f99952c3 100644 --- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte +++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte @@ -2,6 +2,7 @@ import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte'; import { ProgressBarStatus } from '$lib/constants'; + import { languageManager } from '$lib/managers/language-manager.svelte'; import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte'; import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store'; import { AssetTypeEnum } from '@immich/sdk'; @@ -199,7 +200,7 @@ variant="ghost" shape="round" color="secondary" - icon={mdiChevronLeft} + icon={languageManager.rtl ? mdiChevronRight : mdiChevronLeft} onclick={onPrevious} aria-label={$t('previous')} /> @@ -207,7 +208,7 @@ variant="ghost" shape="round" color="secondary" - icon={mdiChevronRight} + icon={languageManager.rtl ? mdiChevronLeft : mdiChevronRight} onclick={onNext} aria-label={$t('next')} /> diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 78fdc3a1ba..e53414be07 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -50,6 +50,7 @@ ); let isScrubbing = $state(false); let showVideo = $state(false); + let hasFocused = $state(false); onMount(() => { // Show video after mount to ensure fading in. @@ -59,6 +60,7 @@ $effect(() => { // reactive on `assetFileUrl` changes if (assetFileUrl) { + hasFocused = false; videoPlayer?.load(); } }); @@ -151,7 +153,10 @@ onseeking={() => (isScrubbing = true)} onseeked={() => (isScrubbing = false)} onplaying={(e) => { - e.currentTarget.focus(); + if (!hasFocused) { + e.currentTarget.focus(); + hasFocused = true; + } }} onclose={() => onClose()} muted={$videoViewerMuted} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index f66e80ef6d..bb67de390e 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -1,4 +1,5 @@
- + {#if !hideMessage} - {$t('error_loading_image')} + {$t('error_loading_image')} {/if}
diff --git a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts index f8e5fe0efa..1d78e24935 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts @@ -8,16 +8,18 @@ vi.hoisted(() => { Object.defineProperty(globalThis, 'matchMedia', { writable: true, enumerable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), // deprecated - removeListener: vi.fn(), // deprecated - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + value: vi.fn().mockImplementation(function (query) { + return { + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), }); }); diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 2b5e9cdf93..64b5a835ed 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -283,8 +283,7 @@
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver} -
-
+
-
- - - -
{/if} @@ -318,7 +312,7 @@
- {#if !usingMobileDevice && !disabled} + {#if !usingMobileDevice && !disabled && !asset.isVideo}
{#if !authManager.isSharedLink && asset.isFavorite} -
+
{/if} {#if !!assetOwner} -
-

+

+

{assetOwner.name}

{/if} {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} -
+
{/if} {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR} -
+
- +
{/if} {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} -
+
- +
{/if} @@ -380,12 +374,12 @@

{asset.stack.assetCount.toLocaleString($locale)}

- +
{/if} diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 28b7ef62ff..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 @@ diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 8ead288490..0bf1a2f7f2 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -16,7 +16,7 @@ import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; + import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; import { IconButton, Logo, toastManager } from '@immich/ui'; import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -48,21 +48,11 @@ const handleUploadAssets = async (files: File[] = []) => { try { - let results: (string | undefined)[] = []; - results = await (!files || files.length === 0 || !Array.isArray(files) + await (!files || files.length === 0 || !Array.isArray(files) ? openFileUploadDialog() : fileUploadHandler({ files })); - const data = await addSharedLinkAssets({ - ...authManager.params, - id: sharedLink.id, - assetIdsDto: { - assetIds: results.filter((id) => !!id) as string[], - }, - }); - const added = data.filter((item) => item.success).length; - - toastManager.success($t('assets_added_count', { values: { count: added } })); + toastManager.success(); } catch (error) { handleError(error, $t('errors.unable_to_add_assets_to_shared_link')); } @@ -84,8 +74,12 @@ }; -
- {#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/apple-header.svelte b/web/src/lib/components/shared-components/apple-header.svelte deleted file mode 100644 index fb45627ede..0000000000 --- a/web/src/lib/components/shared-components/apple-header.svelte +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index be1b73e1c5..7230146886 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -180,6 +180,17 @@ onSelect(selectedOption); }; + // TODO: move this combobox component into @immich/ui + // Bits UI dialogs use `contain: layout` so fixed descendants are positioned in dialog space + const getModalBounds = () => { + const modalRoot = input?.closest('[data-dialog-content]'); + if (!modalRoot || !getComputedStyle(modalRoot).contain.includes('layout')) { + return; + } + + return modalRoot.getBoundingClientRect(); + }; + const calculatePosition = (boundary: DOMRect | undefined) => { const visualViewport = window.visualViewport; @@ -187,29 +198,35 @@ return; } - const left = boundary.left + (visualViewport?.offsetLeft || 0); - const offsetTop = visualViewport?.offsetTop || 0; + const modalBounds = getModalBounds(); + const offsetTop = modalBounds?.top || 0; + const offsetLeft = modalBounds?.left || 0; + const rootHeight = modalBounds?.height || window.innerHeight; + + const top = boundary.top - offsetTop; + const bottom = boundary.bottom - offsetTop; + const left = boundary.left - offsetLeft; if (dropdownDirection === 'top') { return { - bottom: `${window.innerHeight - boundary.top - offsetTop}px`, + bottom: `${rootHeight - top}px`, left: `${left}px`, width: `${boundary.width}px`, - maxHeight: maxHeight(boundary.top - dropdownOffset), + maxHeight: maxHeight(top - dropdownOffset), }; } - const viewportHeight = visualViewport?.height || 0; - const availableHeight = viewportHeight - boundary.bottom; + const viewportHeight = visualViewport?.height || rootHeight; + const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom; return { - top: `${boundary.bottom + offsetTop}px`, + top: `${bottom}px`, left: `${left}px`, width: `${boundary.width}px`, maxHeight: maxHeight(availableHeight - dropdownOffset), }; }; - const maxHeight = (size: number) => `min(${size}px,18rem)`; + const maxHeight = (size: number) => `min(${Math.max(size, 0)}px,18rem)`; const onPositionChange = () => { if (!isOpen) { 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); + +
diff --git a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte index 208de3f8a4..b98ffeab90 100644 --- a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte @@ -2,11 +2,11 @@ import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import { preferences } from '$lib/stores/user.store'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; - import { Checkbox, Icon, Label, Text } from '@immich/ui'; - import { mdiClose } from '@mdi/js'; + import { Checkbox, Label, Text } from '@immich/ui'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; + import TagPill from '../tag-pill.svelte'; interface Props { selectedTags: SvelteSet | null; @@ -73,24 +73,7 @@ {#each selectedTags ?? [] as tagId (tagId)} {@const tag = tagMap[tagId]} {#if tag} -
- -

- {tag.value} -

-
- - -
+ handleRemove(tagId)} /> {/if} {/each} diff --git a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte index 1ca64f6c06..825e5b1fa9 100644 --- a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte +++ b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte @@ -3,17 +3,12 @@ import { userInteraction } from '$lib/stores/user.svelte'; import { getAssetMediaUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; - import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; - import { onMount } from 'svelte'; + import { getAllAlbums } from '@immich/sdk'; import { t } from 'svelte-i18n'; - let albums: AlbumResponseDto[] = $state([]); + let albums = $state(userInteraction.recentAlbums); - onMount(async () => { - if (userInteraction.recentAlbums) { - albums = userInteraction.recentAlbums; - return; - } + const refreshAlbums = async () => { try { const allAlbums = await getAllAlbums({}); albums = allAlbums.sort((a, b) => (a.updatedAt > b.updatedAt ? -1 : 1)).slice(0, 3); @@ -21,6 +16,12 @@ } catch (error) { handleError(error, $t('failed_to_load_assets')); } + }; + + $effect(() => { + if (!userInteraction.recentAlbums) { + void refreshAlbums(); + } }); diff --git a/web/src/lib/components/shared-components/tag-pill.svelte b/web/src/lib/components/shared-components/tag-pill.svelte new file mode 100644 index 0000000000..43148c5954 --- /dev/null +++ b/web/src/lib/components/shared-components/tag-pill.svelte @@ -0,0 +1,31 @@ + + +
+ +

+ {label} +

+
+ + +
diff --git a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte index 76822cc786..1e2533804c 100644 --- a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte @@ -1,4 +1,5 @@ @@ -19,7 +20,7 @@ (isBroken = true)} - class="size-full rounded-xl object-cover aspect-square {className}" + class={cleanClass('size-full rounded-xl object-cover aspect-square', className)} data-testid="album-image" draggable="false" loading={preload ? 'eager' : 'lazy'} diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte index 1e09c6bcfa..319a5e7f9e 100644 --- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte @@ -1,16 +1,18 @@ (null); - const isMouseOverGroup = $derived(hoveredDayGroup !== null); const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150); const filterIntersecting = (intersectables: T[]) => { @@ -54,7 +53,6 @@ {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} - {@const absoluteWidth = dayGroup.left} {@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
(hoveredDayGroup = dayGroup.groupTitle)} onmouseleave={() => (hoveredDayGroup = null)} > - +
onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} > {#if isDayGroupSelected} {:else} - + {/if}
{/if} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 609b5fdc13..d6ce722c96 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -1,7 +1,6 @@ + + - + {selectedDate} diff --git a/web/src/lib/elements/StarRating.svelte b/web/src/lib/elements/StarRating.svelte index f345dc86b7..803e408ec1 100644 --- a/web/src/lib/elements/StarRating.svelte +++ b/web/src/lib/elements/StarRating.svelte @@ -3,27 +3,28 @@ import { shortcuts } from '$lib/actions/shortcut'; import { generateId } from '$lib/utils/generate-id'; import { Icon } from '@immich/ui'; + import { mdiStar, mdiStarOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; + export type Rating = 1 | 2 | 3 | 4 | 5 | null; + interface Props { count?: number; - rating: number; + rating: Rating; readOnly?: boolean; - onRating: (rating: number) => void | undefined; + onRating: (rating: Rating) => void | undefined; } let { count = 5, rating, readOnly = false, onRating }: Props = $props(); let ratingSelection = $derived(rating); - let hoverRating = $state(0); - let focusRating = $state(0); + let hoverRating: Rating = $state(null); + let focusRating: Rating = $state(null); let timeoutId: ReturnType | undefined; - const starIcon = - 'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z'; const id = generateId(); - const handleSelect = (newRating: number) => { + const handleSelect = (newRating: Rating) => { if (readOnly) { return; } @@ -35,7 +36,7 @@ onRating(newRating); }; - const setHoverRating = (value: number) => { + const setHoverRating = (value: Rating) => { if (readOnly) { return; } @@ -43,11 +44,11 @@ }; const reset = () => { - setHoverRating(0); - focusRating = 0; + setHoverRating(null); + focusRating = null; }; - const handleSelectDebounced = (value: number) => { + const handleSelectDebounced = (value: Rating) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => { handleSelect(value); @@ -58,7 +59,7 @@
setHoverRating(0)} + onmouseleave={() => setHoverRating(null)} use:focusOutside={{ onFocusOut: reset }} use:shortcuts={[ { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() }, @@ -69,7 +70,7 @@
{#each { length: count } as _, index (index)} {@const value = index + 1} - {@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)} + {@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value} {@const starId = `${id}-${value}`} @@ -77,19 +78,12 @@ for={starId} class:cursor-pointer={!readOnly} class:ring-2={focusRating === value} - onmouseover={() => setHoverRating(value)} + onmouseover={() => setHoverRating(value as Rating)} tabindex={-1} data-testid="star" > {$t('rating_count', { values: { count: value } })} - + { - focusRating = value; + focusRating = value as Rating; }} - onchange={() => handleSelectDebounced(value)} + onchange={() => handleSelectDebounced(value as Rating)} class="sr-only" /> {/each}
-{#if ratingSelection > 0 && !readOnly} +{#if ratingSelection !== null && !readOnly} -
+ handleRemove(tagId)} /> {/if} {/each} diff --git a/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte b/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte index f12d6cd8a3..d9e215d945 100644 --- a/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte +++ b/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte @@ -1,6 +1,6 @@ diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index c233548878..56c666a17a 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -40,6 +40,7 @@ { key: ['s'], action: $t('stack_selected_photos') }, { key: ['l'], action: $t('add_to_album') }, { key: ['t'], action: $t('tag_assets') }, + { key: ['p'], action: $t('tag_people') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') }, diff --git a/web/src/lib/services/asset.service.spec.ts b/web/src/lib/services/asset.service.spec.ts new file mode 100644 index 0000000000..b67db960be --- /dev/null +++ b/web/src/lib/services/asset.service.spec.ts @@ -0,0 +1,85 @@ +import { getAssetActions, handleDownloadAsset } from '$lib/services/asset.service'; +import { user as userStore } from '$lib/stores/user.store'; +import { setSharedLink } from '$lib/utils'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfo } from '@immich/sdk'; +import { toastManager } from '@immich/ui'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; +import { userAdminFactory } from '@test-data/factories/user-factory'; +import { vitest } from 'vitest'; + +vitest.mock('@immich/ui', () => ({ + toastManager: { + success: vitest.fn(), + }, +})); + +vitest.mock('$lib/utils/i18n', () => ({ + getFormatter: vitest.fn(), + getPreferredLocale: vitest.fn(), +})); + +vitest.mock('@immich/sdk'); + +vitest.mock('$lib/utils', async () => { + const originalModule = await vitest.importActual('$lib/utils'); + return { + ...originalModule, + sleep: vitest.fn(), + }; +}); + +describe('AssetService', () => { + describe('getAssetActions', () => { + it('should allow shared link downloads if the user owns the asset and shared link downloads are disabled', () => { + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: ownerId }); + const asset = assetFactory.build({ ownerId }); + userStore.set(user); + setSharedLink(sharedLinkFactory.build({ allowDownload: false })); + const assetActions = getAssetActions(() => '', asset); + expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true); + }); + + it('should not allow shared link downloads if the user does not own the asset and shared link downloads are disabled', () => { + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: 'non-owner' }); + const asset = assetFactory.build({ ownerId }); + userStore.set(user); + setSharedLink(sharedLinkFactory.build({ allowDownload: false })); + const assetActions = getAssetActions(() => '', asset); + expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(false); + }); + + it('should allow shared link downloads if shared link downloads are enabled regardless of user', () => { + const asset = assetFactory.build(); + setSharedLink(sharedLinkFactory.build({ allowDownload: true })); + const assetActions = getAssetActions(() => '', asset); + expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true); + }); + }); + + describe('handleDownloadAsset', () => { + it('should use the asset originalFileName when showing toasts', async () => { + const $t = vitest.fn().mockReturnValue('formatter'); + vitest.mocked(getFormatter).mockResolvedValue($t); + const asset = assetFactory.build({ originalFileName: 'asset.heic' }); + await handleDownloadAsset(asset, { edited: false }); + expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } }); + expect(toastManager.success).toHaveBeenCalledWith('formatter'); + }); + + it('should use the motion asset originalFileName when showing toasts', async () => { + const $t = vitest.fn().mockReturnValue('formatter'); + vitest.mocked(getFormatter).mockResolvedValue($t); + const motionAsset = assetFactory.build({ originalFileName: 'asset.mov' }); + vitest.mocked(getAssetInfo).mockResolvedValue(motionAsset); + const asset = assetFactory.build({ originalFileName: 'asset.heic', livePhotoVideoId: '1' }); + await handleDownloadAsset(asset, { edited: false }); + expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } }); + expect($t).toHaveBeenNthCalledWith(2, 'downloading_asset_filename', { values: { filename: 'asset.mov' } }); + expect(toastManager.success).toHaveBeenCalledWith('formatter'); + }); + }); +}); diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index bbe4d9301b..5d7ae07684 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -5,11 +5,11 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; +import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { user as authUser, preferences } from '$lib/stores/user.store'; import type { AssetControlContext } from '$lib/types'; import { getSharedLink, sleep } from '$lib/utils'; import { downloadUrl } from '$lib/utils/asset-utils'; -import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { asQueryString } from '$lib/utils/shared-links'; @@ -17,8 +17,6 @@ import { AssetJobName, AssetTypeEnum, AssetVisibility, - copyAsset, - deleteAssets, getAssetInfo, getBaseUrl, runAssetJobs, @@ -34,6 +32,7 @@ import { mdiDatabaseRefreshOutline, mdiDownload, mdiDownloadBox, + mdiFaceRecognition, mdiHeadSyncOutline, mdiHeart, mdiHeartOutline, @@ -106,7 +105,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = title: $t('share'), icon: mdiShareVariantOutline, type: $t('assets'), - $if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked), + $if: () => !!(currentAuthUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked), onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), }; @@ -129,7 +128,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = const SharedLinkDownload: ActionItem = { ...Download, - $if: () => !currentAuthUser && sharedLink && sharedLink.allowDownload, + $if: () => isOwner || !!sharedLink?.allowDownload, }; const PlayMotionPhoto: ActionItem = { @@ -226,6 +225,17 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: { key: 't' }, }; + const TagPeople: ActionItem = { + title: $t('tag_people'), + icon: mdiFaceRecognition, + type: $t('assets'), + $if: () => isOwner && asset.type === AssetTypeEnum.Image && !asset.isTrashed, + onAction: () => { + isFaceEditMode.value = !isFaceEditMode.value; + }, + shortcuts: { key: 'p' }, + }; + const Edit: ActionItem = { title: $t('editor'), icon: mdiTune, @@ -282,6 +292,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = ZoomOut, Copy, Tag, + TagPeople, Edit, RefreshFacesJob, RefreshMetadataJob, @@ -297,7 +308,6 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { { filename: asset.originalFileName, id: asset.id, - size: asset.exifInfo?.fileSizeInByte || 0, }, ]; @@ -311,7 +321,6 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { assets.push({ filename: motionAsset.originalFileName, id: asset.livePhotoVideoId, - size: motionAsset.exifInfo?.fileSizeInByte || 0, }); } } @@ -325,7 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { } try { - toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); + toastManager.success($t('downloading_asset_filename', { values: { filename } })); downloadUrl( getBaseUrl() + `/assets/${id}/original` + @@ -362,14 +371,6 @@ const handleUnfavorite = async (asset: AssetResponseDto) => { } }; -export const handleReplaceAsset = async (oldAssetId: string) => { - const [newAssetId] = await openFileUploadDialog({ multiple: false }); - await copyAsset({ assetCopyDto: { sourceId: oldAssetId, targetId: newAssetId } }); - await deleteAssets({ assetBulkDeleteDto: { ids: [oldAssetId], force: true } }); - - eventManager.emit('AssetReplace', { oldAssetId, newAssetId }); -}; - const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => { const messages: Record = { [AssetJobName.RefreshFaces]: $t('refreshing_faces'), diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index fc4bbe11c0..135c67b95a 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -1,5 +1,4 @@ import { goto } from '$app/navigation'; -import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; @@ -138,7 +137,6 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons try { const results = await removeSharedLinkAssets({ - ...authManager.params, id: sharedLink.id, assetIdsDto: { assetIds }, }); diff --git a/web/src/lib/stores/people.store.ts b/web/src/lib/stores/people.store.ts index 4aeb044b1c..34e927cf36 100644 --- a/web/src/lib/stores/people.store.ts +++ b/web/src/lib/stores/people.store.ts @@ -1,6 +1,7 @@ import { writable } from 'svelte/store'; export interface Faces { + id: string; imageHeight: number; imageWidth: number; boundingBoxX1: number; diff --git a/web/src/lib/stores/user.svelte.ts b/web/src/lib/stores/user.svelte.ts index 508fd156b8..2605feebba 100644 --- a/web/src/lib/stores/user.svelte.ts +++ b/web/src/lib/stores/user.svelte.ts @@ -22,10 +22,17 @@ const defaultUserInteraction: UserInteractions = { export const userInteraction = $state(defaultUserInteraction); +const resetRecentAlbums = () => { + userInteraction.recentAlbums = undefined; +}; + const reset = () => { Object.assign(userInteraction, defaultUserInteraction); }; eventManager.on({ + AlbumCreate: () => resetRecentAlbums(), + AlbumUpdate: () => resetRecentAlbums(), + AlbumDelete: () => resetRecentAlbums(), AuthLogout: () => reset(), }); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 204e44f84e..8d86fc9749 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -62,7 +62,10 @@ export const websocketStore = { export const websocketEvents = createEventEmitter(websocket); websocket - .on('connect', () => websocketStore.connected.set(true)) + .on('connect', () => { + eventManager.emit('WebsocketConnect'); + websocketStore.connected.set(true); + }) .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('AppRestartV1', (mode) => websocketStore.serverRestarting.set(mode)) diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 3bc8665279..221fc38568 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -74,6 +74,32 @@ describe('utils', () => { expect(url).toContain(asset.id); }); + it('should return original URL for video assets with forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset, forceOriginal: true }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for video assets without forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => { const asset = assetFactory.build({ originalPath: 'image.gif', diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 3204b35576..9d0c32ae94 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -7,7 +7,6 @@ import { AssetMediaSize, AssetTypeEnum, MemoryType, - QueueName, finishOAuth, getAssetOriginalPath, getAssetPlaybackPath, @@ -144,37 +143,10 @@ export const downloadRequest = (options: DownloadRequestOptions }); }; -export const getQueueName = derived(t, ($t) => { - return (name: QueueName) => { - const names: Record = { - [QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'), - [QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'), - [QueueName.Sidecar]: $t('admin.sidecar_job'), - [QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'), - [QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'), - [QueueName.FaceDetection]: $t('admin.face_detection'), - [QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'), - [QueueName.VideoConversion]: $t('admin.video_conversion_job'), - [QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'), - [QueueName.Migration]: $t('admin.migration_job'), - [QueueName.BackgroundTask]: $t('admin.background_task_job'), - [QueueName.Search]: $t('search'), - [QueueName.Library]: $t('external_libraries'), - [QueueName.Notifications]: $t('notifications'), - [QueueName.BackupDatabase]: $t('admin.backup_database'), - [QueueName.Ocr]: $t('admin.machine_learning_ocr'), - [QueueName.Workflow]: $t('workflows'), - [QueueName.Editor]: $t('editor'), - }; - - return names[name]; - }; -}); - let _sharedLink: SharedLinkResponseDto | undefined; -export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink); -export const getSharedLink = (): SharedLinkResponseDto | undefined => _sharedLink; +export const setSharedLink = (sharedLink: typeof _sharedLink) => (_sharedLink = sharedLink); +export const getSharedLink = (): typeof _sharedLink => _sharedLink; const createUrl = (path: string, parameters?: Record) => { const searchParameters = new URLSearchParams(); @@ -214,13 +186,23 @@ export const getAssetUrl = ({ return getAssetMediaUrl({ id, size, cacheKey }); }; +export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkResponseDto) { + return { + thumbnail: getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }), + preview: getAssetUrl({ asset, sharedLink })!, + original: getAssetUrl({ asset, sharedLink, forceOriginal: true })!, + }; +} + const forceUseOriginal = (asset: AssetResponseDto) => { return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); }; export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) { - return isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize; + return asset.type === AssetTypeEnum.Video || isWebCompatibleImage(asset) + ? AssetMediaSize.Original + : AssetMediaSize.Fullsize; } return AssetMediaSize.Preview; }; diff --git a/web/src/lib/utils/adaptive-image-loader.spec.ts b/web/src/lib/utils/adaptive-image-loader.spec.ts new file mode 100644 index 0000000000..f4a2b172cc --- /dev/null +++ b/web/src/lib/utils/adaptive-image-loader.spec.ts @@ -0,0 +1,304 @@ +import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; +import { cancelImageUrl } from '$lib/utils/sw-messaging'; + +vi.mock('$lib/utils/sw-messaging', () => ({ + cancelImageUrl: vi.fn(), +})); + +function createQualityList(overrides?: { + onAfterLoad?: Record void>; + onAfterError?: Record void>; +}): QualityList { + return [ + { + quality: 'thumbnail', + url: '/thumbnail.jpg', + onAfterLoad: overrides?.onAfterLoad?.thumbnail, + onAfterError: overrides?.onAfterError?.thumbnail, + }, + { + quality: 'preview', + url: '/preview.jpg', + onAfterLoad: overrides?.onAfterLoad?.preview, + onAfterError: overrides?.onAfterError?.preview, + }, + { + quality: 'original', + url: '/original.jpg', + onAfterLoad: overrides?.onAfterLoad?.original, + onAfterError: overrides?.onAfterError?.original, + }, + ]; +} + +describe('AdaptiveImageLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('initializes with thumbnail URL set', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + expect(loader.status.urls.thumbnail).toBe('/thumbnail.jpg'); + expect(loader.status.urls.preview).toBeUndefined(); + expect(loader.status.urls.original).toBeUndefined(); + }); + + it('initializes all qualities as unloaded', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + expect(loader.status.quality.thumbnail).toBe('unloaded'); + expect(loader.status.quality.preview).toBe('unloaded'); + expect(loader.status.quality.original).toBe('unloaded'); + }); + }); + + describe('onStart', () => { + it('sets started to true', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + expect(loader.status.started).toBe(false); + loader.onStart('thumbnail'); + expect(loader.status.started).toBe(true); + }); + + it('is a no-op after destroy', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + loader.destroy(); + loader.onStart('thumbnail'); + expect(loader.status.started).toBe(false); + }); + }); + + describe('onLoad', () => { + it('sets quality to success and calls callbacks', () => { + const onUrlChange = vi.fn(); + const onImageReady = vi.fn(); + const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange, onImageReady }); + + loader.onLoad('thumbnail'); + + expect(loader.status.quality.thumbnail).toBe('success'); + expect(onUrlChange).toHaveBeenCalledWith('/thumbnail.jpg'); + expect(onImageReady).toHaveBeenCalledOnce(); + }); + + it('calls onAfterLoad callback', () => { + const onAfterLoad = vi.fn(); + const qualityList = createQualityList({ onAfterLoad: { thumbnail: onAfterLoad } }); + const loader = new AdaptiveImageLoader(qualityList); + + loader.onLoad('thumbnail'); + + expect(onAfterLoad).toHaveBeenCalledWith(loader); + }); + + it('ignores load if URL is not set', () => { + const onImageReady = vi.fn(); + const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady }); + + loader.onLoad('preview'); + + expect(loader.status.quality.preview).toBe('unloaded'); + expect(onImageReady).not.toHaveBeenCalled(); + }); + + it('ignores load if a higher quality is already loaded', () => { + const onUrlChange = vi.fn(); + const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange }); + + loader.onLoad('thumbnail'); + loader.trigger('preview'); + loader.onLoad('preview'); + + onUrlChange.mockClear(); + loader.onLoad('thumbnail'); + + expect(onUrlChange).not.toHaveBeenCalled(); + }); + + it('is a no-op after destroy', () => { + const onImageReady = vi.fn(); + const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady }); + + loader.destroy(); + loader.onLoad('thumbnail'); + + expect(onImageReady).not.toHaveBeenCalled(); + }); + }); + + describe('onError', () => { + it('sets quality to error and clears URL', () => { + const onError = vi.fn(); + const loader = new AdaptiveImageLoader(createQualityList(), { onError }); + + loader.onError('thumbnail'); + + expect(loader.status.quality.thumbnail).toBe('error'); + expect(loader.status.urls.thumbnail).toBeUndefined(); + expect(loader.status.hasError).toBe(true); + expect(onError).toHaveBeenCalledOnce(); + }); + + it('calls onAfterError callback', () => { + const onAfterError = vi.fn(); + const qualityList = createQualityList({ onAfterError: { thumbnail: onAfterError } }); + const loader = new AdaptiveImageLoader(qualityList); + + loader.onError('thumbnail'); + + expect(onAfterError).toHaveBeenCalledWith(loader); + }); + + it('is a no-op after destroy', () => { + const onError = vi.fn(); + const loader = new AdaptiveImageLoader(createQualityList(), { onError }); + + loader.destroy(); + loader.onError('thumbnail'); + + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('trigger', () => { + it('sets the URL for the quality', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + + loader.trigger('preview'); + + expect(loader.status.urls.preview).toBe('/preview.jpg'); + }); + + it('returns true if URL is already set', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + + expect(loader.trigger('thumbnail')).toBe(true); + }); + + it('returns false when triggering a new quality', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + + expect(loader.trigger('preview')).toBe(false); + }); + + it('clears hasError when triggering', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + + loader.onError('thumbnail'); + expect(loader.status.hasError).toBe(true); + + loader.trigger('preview'); + expect(loader.status.hasError).toBe(false); + }); + + it('calls imageLoader when provided', () => { + const imageLoader = vi.fn(() => vi.fn()); + const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader); + + loader.trigger('preview'); + + expect(imageLoader).toHaveBeenCalledWith( + '/preview.jpg', + expect.any(Function), + expect.any(Function), + expect.any(Function), + ); + }); + + it('returns false after destroy', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + + loader.destroy(); + + expect(loader.trigger('preview')).toBe(false); + }); + + it('calls onAfterError if URL is empty', () => { + const onAfterError = vi.fn(); + const qualityList = createQualityList({ onAfterError: { preview: onAfterError } }); + (qualityList[1] as { url: string }).url = ''; + const loader = new AdaptiveImageLoader(qualityList); + + expect(loader.trigger('preview')).toBe(false); + expect(onAfterError).toHaveBeenCalledWith(loader); + }); + }); + + describe('start', () => { + it('throws if no imageLoader is provided', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + expect(() => loader.start()).toThrow('Start requires imageLoader to be specified'); + }); + + it('calls imageLoader with thumbnail URL', () => { + const imageLoader = vi.fn(() => vi.fn()); + const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader); + + loader.start(); + + expect(imageLoader).toHaveBeenCalledWith( + '/thumbnail.jpg', + expect.any(Function), + expect.any(Function), + expect.any(Function), + ); + }); + }); + + describe('destroy', () => { + it('cancels all image URLs when no imageLoader', () => { + const loader = new AdaptiveImageLoader(createQualityList()); + + loader.destroy(); + + expect(cancelImageUrl).toHaveBeenCalledWith('/thumbnail.jpg'); + expect(cancelImageUrl).toHaveBeenCalledWith('/preview.jpg'); + expect(cancelImageUrl).toHaveBeenCalledWith('/original.jpg'); + }); + + it('calls destroy functions when imageLoader is provided', () => { + const destroyFn = vi.fn(); + const imageLoader = vi.fn(() => destroyFn); + const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader); + + loader.start(); + loader.destroy(); + + expect(destroyFn).toHaveBeenCalledOnce(); + expect(cancelImageUrl).not.toHaveBeenCalled(); + }); + }); + + describe('progressive loading flow', () => { + it('thumbnail load triggers preview via onAfterLoad', () => { + const triggerSpy = vi.fn(); + const qualityList = createQualityList({ + onAfterLoad: { + thumbnail: (loader) => { + triggerSpy(); + loader.trigger('preview'); + }, + }, + }); + const loader = new AdaptiveImageLoader(qualityList); + + loader.onLoad('thumbnail'); + + expect(triggerSpy).toHaveBeenCalledOnce(); + expect(loader.status.urls.preview).toBe('/preview.jpg'); + }); + + it('thumbnail error triggers preview via onAfterError', () => { + const qualityList = createQualityList({ + onAfterError: { + thumbnail: (loader) => loader.trigger('preview'), + }, + }); + const loader = new AdaptiveImageLoader(qualityList); + + loader.onError('thumbnail'); + + expect(loader.status.urls.preview).toBe('/preview.jpg'); + }); + }); +}); diff --git a/web/src/lib/utils/adaptive-image-loader.svelte.ts b/web/src/lib/utils/adaptive-image-loader.svelte.ts new file mode 100644 index 0000000000..8d9a5f79f4 --- /dev/null +++ b/web/src/lib/utils/adaptive-image-loader.svelte.ts @@ -0,0 +1,164 @@ +import type { LoadImageFunction } from '$lib/actions/image-loader.svelte'; +import { cancelImageUrl } from '$lib/utils/sw-messaging'; + +export type ImageQuality = 'thumbnail' | 'preview' | 'original'; + +export type ImageStatus = 'unloaded' | 'success' | 'error'; + +export type ImageLoaderStatus = { + urls: Record; + quality: Record; + started: boolean; + hasError: boolean; +}; + +type ImageLoaderCallbacks = { + onUrlChange?: (url: string) => void; + onImageReady?: () => void; + onError?: () => void; +}; + +export type QualityConfig = { + url: string; + quality: ImageQuality; + onAfterLoad?: (loader: AdaptiveImageLoader) => void; + onAfterError?: (loader: AdaptiveImageLoader) => void; +}; + +export type QualityList = [ + QualityConfig & { quality: 'thumbnail' }, + QualityConfig & { quality: 'preview' }, + QualityConfig & { quality: 'original' }, +]; + +export class AdaptiveImageLoader { + private destroyFunctions: (() => void)[] = []; + private qualityConfigs: Record; + private highestLoadedQualityIndex = -1; + private destroyed = false; + + status = $state({ + started: false, + hasError: false, + urls: { thumbnail: undefined, preview: undefined, original: undefined }, + quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' }, + }); + + constructor( + private readonly qualityList: QualityList, + private readonly callbacks?: ImageLoaderCallbacks, + private readonly imageLoader?: LoadImageFunction, + ) { + this.qualityConfigs = { + thumbnail: qualityList[0], + preview: qualityList[1], + original: qualityList[2], + }; + this.status.urls.thumbnail = qualityList[0].url; + } + + start() { + if (!this.imageLoader) { + throw new Error('Start requires imageLoader to be specified'); + } + + this.destroyFunctions.push( + this.imageLoader( + this.qualityList[0].url, + () => this.onLoad('thumbnail'), + () => this.onError('thumbnail'), + () => this.onStart('thumbnail'), + ), + ); + } + + onStart(_: ImageQuality) { + if (this.destroyed) { + return; + } + this.status.started = true; + } + + onLoad(quality: ImageQuality) { + if (this.destroyed) { + return; + } + + const config = this.qualityConfigs[quality]; + + if (!this.status.urls[quality]) { + return; + } + + const index = this.qualityList.indexOf(config); + if (index <= this.highestLoadedQualityIndex) { + return; + } + + this.highestLoadedQualityIndex = index; + this.status.quality[quality] = 'success'; + this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url); + this.callbacks?.onImageReady?.(); + + config.onAfterLoad?.(this); + } + + onError(quality: ImageQuality) { + if (this.destroyed) { + return; + } + + const config = this.qualityConfigs[quality]; + + this.status.hasError = true; + this.status.quality[quality] = 'error'; + this.status.urls[quality] = undefined; + this.callbacks?.onError?.(); + + config.onAfterError?.(this); + } + + trigger(quality: ImageQuality) { + if (this.destroyed) { + return false; + } + + const url = this.qualityConfigs[quality].url; + if (!url) { + this.qualityConfigs[quality].onAfterError?.(this); + return false; + } + + if (this.status.urls[quality]) { + return true; + } + + this.status.hasError = false; + this.status.urls[quality] = url; + if (this.imageLoader) { + this.destroyFunctions.push( + this.imageLoader( + url, + () => this.onLoad(quality), + () => this.onError(quality), + () => this.onStart(quality), + ), + ); + } + return false; + } + + destroy() { + this.destroyed = true; + if (this.imageLoader) { + for (const destroy of this.destroyFunctions) { + destroy(); + } + return; + } + + for (const config of Object.values(this.qualityConfigs)) { + cancelImageUrl(config.url); + } + } +} diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index 1689d43585..497642847d 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -1,4 +1,5 @@ import { goto } from '$app/navigation'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { Route } from '$lib/route'; import { AlbumFilter, @@ -29,6 +30,7 @@ export const createAlbum = async (name?: string, assetIds?: string[]) => { assetIds, }, }); + eventManager.emit('AlbumCreate', newAlbum); return newAlbum; } catch (error) { const $t = get(t); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 73a6965dd9..6f0363c74a 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -224,19 +224,56 @@ const supportedImageMimeTypes = new Set([ 'image/webp', ]); -const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 -if (isSafari) { - supportedImageMimeTypes.add('image/heic').add('image/heif'); -} +export const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.includes('Firefox'); -function checkJxlSupport(): void { - const img = new Image(); - img.addEventListener('load', () => { +async function addSupportedMimeTypes(): Promise { + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 + if (isSafari) { + const match = navigator.userAgent.match(/Version\/(\d+)/); + + if (!match) { + return; + } + + const majorVersion = Number.parseInt(match[1]); + const MIN_REQUIRED_VERSION = 17; + + if (majorVersion >= MIN_REQUIRED_VERSION) { + supportedImageMimeTypes.add('image/jxl').add('image/heic').add('image/heif'); + } + + return; + } + + if (globalThis.isSecureContext && typeof ImageDecoder !== 'undefined') { + const dynamicMimeTypes = [{ type: 'image/jxl' }, { type: 'image/heic', aliases: ['image/heif'] }]; + + for (const mime of dynamicMimeTypes) { + const isMimeTypeSupported = await ImageDecoder.isTypeSupported(mime.type); + if (isMimeTypeSupported) { + for (const mimeType of [mime.type, ...(mime.aliases || [])]) { + supportedImageMimeTypes.add(mimeType); + } + } + } + + return; + } + + const jxlImg = new Image(); + jxlImg.addEventListener('load', () => { supportedImageMimeTypes.add('image/jxl'); }); - img.src = 'data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgA='; // Small valid JPEG XL image + jxlImg.src = 'data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgA='; // Small valid JPEG XL image + + const heicImg = new Image(); + heicImg.addEventListener('load', () => { + supportedImageMimeTypes.add('image/heic'); + }); + heicImg.src = + 'data:image/heic;base64,AAAAGGZ0eXBoZWljAAAAAG1pZjFoZWljAAABrW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAADnBpdG0AAAAAAAIAAAAQaWRhdAAAAAAAAQABAAAAOGlsb2MBAAAAREAAAgABAAAAAAAAAc0AAQAAAAAAAAAsAAIAAQAAAAAAAAABAAAAAAAAAAgAAAA4aWluZgAAAAAAAgAAABVpbmZlAgAAAQABAABodmMxAAAAABVpbmZlAgAAAAACAABncmlkAAAAANhpcHJwAAAAtmlwY28AAAB2aHZjQwEDcAAAAAAAAAAAAB7wAPz9+PgAAA8DIAABABhAAQwB//8DcAAAAwCQAAADAAADAB66AkAhAAEAKkIBAQNwAAADAJAAAAMAAAMAHqAggQWW6q6a5uBAQMCAAAADAIAAAAMAhCIAAQAGRAHBc8GJAAAAFGlzcGUAAAAAAAAAAQAAAAEAAAAUaXNwZQAAAAAAAABAAAAAQAAAABBwaXhpAAAAAAMICAgAAAAaaXBtYQAAAAAAAAACAAECgQMAAgIChAAAABppcmVmAAAAAAAAAA5kaW1nAAIAAQABAAAANG1kYXQAAAAoKAGvCchMZYA50NoPIfzz81Qfsm577GJt3lf8kLAr+NbNIoeRR7JeYA=='; // Small valid HEIC/HEIF image } -checkJxlSupport(); +void addSupportedMimeTypes(); /** * Returns true if the asset is an image supported by web browsers, false otherwise diff --git a/web/src/lib/utils/container-utils.spec.ts b/web/src/lib/utils/container-utils.spec.ts new file mode 100644 index 0000000000..802ed24e40 --- /dev/null +++ b/web/src/lib/utils/container-utils.spec.ts @@ -0,0 +1,94 @@ +import { getContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; + +const mockImage = (props: { + naturalWidth: number; + naturalHeight: number; + width: number; + height: number; +}): HTMLImageElement => props as unknown as HTMLImageElement; + +const mockVideo = (props: { + videoWidth: number; + videoHeight: number; + clientWidth: number; + clientHeight: number; +}): HTMLVideoElement => { + const element = Object.create(HTMLVideoElement.prototype); + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(element, key, { value, writable: true, configurable: true }); + } + return element; +}; + +describe('scaleToFit', () => { + it('should return full width when image is wider than container', () => { + expect(scaleToFit({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({ width: 800, height: 400 }); + }); + + it('should return full height when image is taller than container', () => { + expect(scaleToFit({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({ width: 300, height: 600 }); + }); + + it('should return exact fit when aspect ratios match', () => { + expect(scaleToFit({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({ width: 800, height: 450 }); + }); + + it('should handle square images in landscape container', () => { + expect(scaleToFit({ width: 500, height: 500 }, { width: 800, height: 600 })).toEqual({ width: 600, height: 600 }); + }); + + it('should handle square images in portrait container', () => { + expect(scaleToFit({ width: 500, height: 500 }, { width: 400, height: 600 })).toEqual({ width: 400, height: 400 }); + }); +}); + +describe('getContentMetrics', () => { + it('should compute zero offsets when aspect ratios match', () => { + const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 }); + expect(getContentMetrics(img)).toEqual({ + contentWidth: 800, + contentHeight: 450, + offsetX: 0, + offsetY: 0, + }); + }); + + it('should compute horizontal letterbox offsets for tall image', () => { + const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 }); + const metrics = getContentMetrics(img); + expect(metrics.contentWidth).toBe(300); + expect(metrics.contentHeight).toBe(600); + expect(metrics.offsetX).toBe(250); + expect(metrics.offsetY).toBe(0); + }); + + it('should compute vertical letterbox offsets for wide image', () => { + const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 }); + const metrics = getContentMetrics(img); + expect(metrics.contentWidth).toBe(800); + expect(metrics.contentHeight).toBe(400); + expect(metrics.offsetX).toBe(0); + expect(metrics.offsetY).toBe(100); + }); + + it('should use clientWidth/clientHeight for video elements', () => { + const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 }); + const metrics = getContentMetrics(video); + expect(metrics.contentWidth).toBe(800); + expect(metrics.contentHeight).toBe(450); + expect(metrics.offsetX).toBe(0); + expect(metrics.offsetY).toBe(75); + }); +}); + +describe('getNaturalSize', () => { + it('should return naturalWidth/naturalHeight for images', () => { + const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 }); + expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 }); + }); + + it('should return videoWidth/videoHeight for videos', () => { + const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 }); + expect(getNaturalSize(video)).toEqual({ width: 1920, height: 1080 }); + }); +}); diff --git a/web/src/lib/utils/container-utils.ts b/web/src/lib/utils/container-utils.ts new file mode 100644 index 0000000000..ffa2fae769 --- /dev/null +++ b/web/src/lib/utils/container-utils.ts @@ -0,0 +1,58 @@ +export interface ContentMetrics { + contentWidth: number; + contentHeight: number; + offsetX: number; + offsetY: number; +} + +export const scaleToCover = ( + dimensions: { width: number; height: number }, + container: { width: number; height: number }, +): { width: number; height: number } => { + const scaleX = container.width / dimensions.width; + const scaleY = container.height / dimensions.height; + const scale = Math.max(scaleX, scaleY); + return { + width: dimensions.width * scale, + height: dimensions.height * scale, + }; +}; + +export const scaleToFit = ( + dimensions: { width: number; height: number }, + container: { width: number; height: number }, +): { width: number; height: number } => { + const scaleX = container.width / dimensions.width; + const scaleY = container.height / dimensions.height; + const scale = Math.min(scaleX, scaleY); + return { + width: dimensions.width * scale, + height: dimensions.height * scale, + }; +}; + +const getElementSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => { + if (element instanceof HTMLVideoElement) { + return { width: element.clientWidth, height: element.clientHeight }; + } + return { width: element.width, height: element.height }; +}; + +export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => { + if (element instanceof HTMLVideoElement) { + return { width: element.videoWidth, height: element.videoHeight }; + } + return { width: element.naturalWidth, height: element.naturalHeight }; +}; + +export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => { + const natural = getNaturalSize(element); + const client = getElementSize(element); + const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client); + return { + contentWidth, + contentHeight, + offsetX: (client.width - contentWidth) / 2, + offsetY: (client.height - contentHeight) / 2, + }; +}; diff --git a/web/src/lib/utils/layout-utils.spec.ts b/web/src/lib/utils/layout-utils.spec.ts new file mode 100644 index 0000000000..94f1ffb335 --- /dev/null +++ b/web/src/lib/utils/layout-utils.spec.ts @@ -0,0 +1,54 @@ +import { scaleToFit } from '$lib/utils/container-utils'; + +describe('scaleToFit', () => { + const tests = [ + { + name: 'landscape image in square container', + dimensions: { width: 2000, height: 1000 }, + container: { width: 500, height: 500 }, + expected: { width: 500, height: 250 }, + }, + { + name: 'portrait image in square container', + dimensions: { width: 1000, height: 2000 }, + container: { width: 500, height: 500 }, + expected: { width: 250, height: 500 }, + }, + { + name: 'square image in square container', + dimensions: { width: 1000, height: 1000 }, + container: { width: 500, height: 500 }, + expected: { width: 500, height: 500 }, + }, + { + name: 'landscape image in landscape container', + dimensions: { width: 1600, height: 900 }, + container: { width: 800, height: 600 }, + expected: { width: 800, height: 450 }, + }, + { + name: 'portrait image in portrait container', + dimensions: { width: 900, height: 1600 }, + container: { width: 600, height: 800 }, + expected: { width: 450, height: 800 }, + }, + { + name: 'image matches container exactly', + dimensions: { width: 500, height: 300 }, + container: { width: 500, height: 300 }, + expected: { width: 500, height: 300 }, + }, + { + name: 'image smaller than container scales up', + dimensions: { width: 100, height: 50 }, + container: { width: 400, height: 400 }, + expected: { width: 400, height: 200 }, + }, + ]; + + for (const { name, dimensions, container, expected } of tests) { + it(`should handle ${name}`, () => { + expect(scaleToFit(dimensions, container)).toEqual(expected); + }); + } +}); diff --git a/web/src/lib/utils/ocr-utils.spec.ts b/web/src/lib/utils/ocr-utils.spec.ts new file mode 100644 index 0000000000..c3ce70394d --- /dev/null +++ b/web/src/lib/utils/ocr-utils.spec.ts @@ -0,0 +1,116 @@ +import type { OcrBoundingBox } from '$lib/stores/ocr.svelte'; +import type { ContentMetrics } from '$lib/utils/container-utils'; +import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; + +describe('getOcrBoundingBoxes', () => { + it('should scale normalized coordinates by display dimensions', () => { + const ocrData: OcrBoundingBox[] = [ + { + id: 'box1', + assetId: 'asset1', + x1: 0.1, + y1: 0.2, + x2: 0.9, + y2: 0.2, + x3: 0.9, + y3: 0.8, + x4: 0.1, + y4: 0.8, + boxScore: 0.95, + textScore: 0.9, + text: 'hello', + }, + ]; + const metrics: ContentMetrics = { contentWidth: 1000, contentHeight: 500, offsetX: 0, offsetY: 0 }; + + const boxes = getOcrBoundingBoxes(ocrData, metrics); + + expect(boxes).toHaveLength(1); + expect(boxes[0].id).toBe('box1'); + expect(boxes[0].text).toBe('hello'); + expect(boxes[0].confidence).toBe(0.9); + expect(boxes[0].points).toEqual([ + { x: 100, y: 100 }, + { x: 900, y: 100 }, + { x: 900, y: 400 }, + { x: 100, y: 400 }, + ]); + }); + + it('should apply offsets for letterboxed images', () => { + const ocrData: OcrBoundingBox[] = [ + { + id: 'box1', + assetId: 'asset1', + x1: 0, + y1: 0, + x2: 1, + y2: 0, + x3: 1, + y3: 1, + x4: 0, + y4: 1, + boxScore: 0.9, + textScore: 0.8, + text: 'test', + }, + ]; + const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 400, offsetX: 100, offsetY: 50 }; + + const boxes = getOcrBoundingBoxes(ocrData, metrics); + + expect(boxes[0].points).toEqual([ + { x: 100, y: 50 }, + { x: 700, y: 50 }, + { x: 700, y: 450 }, + { x: 100, y: 450 }, + ]); + }); + + it('should return empty array for empty input', () => { + const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 }; + expect(getOcrBoundingBoxes([], metrics)).toEqual([]); + }); + + it('should handle multiple boxes', () => { + const ocrData: OcrBoundingBox[] = [ + { + id: 'a', + assetId: 'asset1', + x1: 0, + y1: 0, + x2: 0.5, + y2: 0, + x3: 0.5, + y3: 0.5, + x4: 0, + y4: 0.5, + boxScore: 0.9, + textScore: 0.8, + text: 'first', + }, + { + id: 'b', + assetId: 'asset1', + x1: 0.5, + y1: 0.5, + x2: 1, + y2: 0.5, + x3: 1, + y3: 1, + x4: 0.5, + y4: 1, + boxScore: 0.9, + textScore: 0.7, + text: 'second', + }, + ]; + const metrics: ContentMetrics = { contentWidth: 200, contentHeight: 200, offsetX: 0, offsetY: 0 }; + + const boxes = getOcrBoundingBoxes(ocrData, metrics); + + expect(boxes).toHaveLength(2); + expect(boxes[0].text).toBe('first'); + expect(boxes[1].text).toBe('second'); + }); +}); diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 01f118a4e5..c483eb9551 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -1,29 +1,38 @@ import type { OcrBoundingBox } from '$lib/stores/ocr.svelte'; -import type { ZoomImageWheelState } from '@zoom-image/core'; - -const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => { - const ratio = img.naturalWidth / img.naturalHeight; - let width = img.height * ratio; - let height = img.height; - if (width > img.width) { - width = img.width; - height = img.width / ratio; - } - return { width, height }; -}; +import type { ContentMetrics } from '$lib/utils/container-utils'; +import { clamp } from 'lodash-es'; export type Point = { x: number; y: number; }; +const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + +export type VerticalMode = 'none' | 'cjk' | 'rotated'; + export interface OcrBox { id: string; points: Point[]; text: string; confidence: number; + verticalMode: VerticalMode; } +const CJK_PATTERN = + /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/; + +const VERTICAL_ASPECT_RATIO = 1.5; + +const containsCjk = (text: string): boolean => CJK_PATTERN.test(text); + +const getVerticalMode = (width: number, height: number, text: string): VerticalMode => { + if (height / width < VERTICAL_ASPECT_RATIO) { + return 'none'; + } + return containsCjk(text) ? 'cjk' : 'rotated'; +}; + /** * Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d. * @param points - Array of 4 corner points of the bounding box @@ -32,8 +41,6 @@ export interface OcrBox { export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => { const [topLeft, topRight, bottomRight, bottomLeft] = points; - // Approximate width and height to prevent text distortion as much as possible - const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight)); const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight)); @@ -66,62 +73,129 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; return { matrix, width, height }; }; -/** - * Convert normalized OCR coordinates to screen coordinates - * OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle - */ -export const getOcrBoundingBoxes = ( - ocrData: OcrBoundingBox[], - zoom: ZoomImageWheelState, - photoViewer: HTMLImageElement | null, -): OcrBox[] => { - if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) { - return []; +const BORDER_SIZE = 4; +const HORIZONTAL_PADDING = 16 + BORDER_SIZE; +const VERTICAL_PADDING = 8 + BORDER_SIZE; +const REFERENCE_FONT_SIZE = 100; +const MIN_FONT_SIZE = 8; +const MAX_FONT_SIZE = 96; +const FALLBACK_FONT = `${REFERENCE_FONT_SIZE}px sans-serif`; + +let sharedCanvasContext: CanvasRenderingContext2D | null = null; +let resolvedFont: string | undefined; + +const getCanvasContext = (): CanvasRenderingContext2D | null => { + if (sharedCanvasContext !== null) { + return sharedCanvasContext; } - - const clientHeight = photoViewer.clientHeight; - const clientWidth = photoViewer.clientWidth; - const { width, height } = getContainedSize(photoViewer); - - const offset = { - x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX, - y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY, - }; - - return getOcrBoundingBoxesAtSize( - ocrData, - { width: width * zoom.currentZoom, height: height * zoom.currentZoom }, - offset, - ); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + sharedCanvasContext = context; + return sharedCanvasContext; }; -export const getOcrBoundingBoxesAtSize = ( - ocrData: OcrBoundingBox[], - targetSize: { width: number; height: number }, - offset?: Point, -) => { - const boxes: OcrBox[] = []; +const getReferenceFont = (): string => { + if (resolvedFont !== undefined) { + return resolvedFont; + } + const fontFamily = globalThis.getComputedStyle?.(document.documentElement).getPropertyValue('--font-sans').trim(); + resolvedFont = fontFamily ? `${REFERENCE_FONT_SIZE}px ${fontFamily}` : FALLBACK_FONT; + return resolvedFont; +}; +export const calculateFittedFontSize = ( + text: string, + boxWidth: number, + boxHeight: number, + verticalMode: VerticalMode, +): number => { + const isVertical = verticalMode === 'cjk' || verticalMode === 'rotated'; + const availableWidth = boxWidth - (isVertical ? VERTICAL_PADDING : HORIZONTAL_PADDING); + const availableHeight = boxHeight - (isVertical ? HORIZONTAL_PADDING : VERTICAL_PADDING); + + const context = getCanvasContext(); + + if (verticalMode === 'cjk') { + if (!context) { + const fontSize = Math.min(availableWidth, availableHeight / text.length); + return clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + let maxCharWidth = 0; + let totalCharHeight = 0; + for (const character of text) { + const metrics = context.measureText(character); + const charWidth = metrics.width; + const charHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + maxCharWidth = Math.max(maxCharWidth, charWidth); + totalCharHeight += Math.max(charWidth, charHeight); + } + + const scaleFromWidth = (availableWidth / maxCharWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (availableHeight / totalCharHeight) * REFERENCE_FONT_SIZE; + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + const fitWidth = verticalMode === 'rotated' ? availableHeight : availableWidth; + const fitHeight = verticalMode === 'rotated' ? availableWidth : availableHeight; + + if (!context) { + return clamp((1.4 * fitWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // Unsupported in Safari iOS <16.6; falls back to default canvas font, giving less accurate but functional sizing + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + const metrics = context.measureText(text); + const measuredWidth = metrics.width; + const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + + const scaleFromWidth = (fitWidth / measuredWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (fitHeight / measuredHeight) * REFERENCE_FONT_SIZE; + + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); +}; + +export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => { + const boxes: OcrBox[] = []; for (const ocr of ocrData) { - // Convert normalized coordinates (0-1) to actual pixel positions - // OCR provides 4 corners of a potentially rotated rectangle const points = [ { x: ocr.x1, y: ocr.y1 }, { x: ocr.x2, y: ocr.y2 }, { x: ocr.x3, y: ocr.y3 }, { x: ocr.x4, y: ocr.y4 }, ].map((point) => ({ - x: targetSize.width * point.x + (offset?.x ?? 0), - y: targetSize.height * point.y + (offset?.y ?? 0), + x: point.x * metrics.contentWidth + metrics.offsetX, + y: point.y * metrics.contentHeight + metrics.offsetY, })); + const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2])); + const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2])); + boxes.push({ id: ocr.id, points, text: ocr.text, confidence: ocr.textScore, + verticalMode: getVerticalMode(boxWidth, boxHeight, ocr.text), }); } + const rowThreshold = metrics.contentHeight * 0.02; + boxes.sort((a, b) => { + const yDifference = a.points[0].y - b.points[0].y; + if (Math.abs(yDifference) < rowThreshold) { + return a.points[0].x - b.points[0].x; + } + return yDifference; + }); + return boxes; }; diff --git a/web/src/lib/utils/people-utils.spec.ts b/web/src/lib/utils/people-utils.spec.ts new file mode 100644 index 0000000000..80371bd9c4 --- /dev/null +++ b/web/src/lib/utils/people-utils.spec.ts @@ -0,0 +1,99 @@ +import type { Faces } from '$lib/stores/people.store'; +import type { ContentMetrics } from '$lib/utils/container-utils'; +import { getBoundingBox } from '$lib/utils/people-utils'; + +const makeFace = (overrides: Partial = {}): Faces => ({ + id: 'face-1', + imageWidth: 4000, + imageHeight: 3000, + boundingBoxX1: 1000, + boundingBoxY1: 750, + boundingBoxX2: 2000, + boundingBoxY2: 1500, + ...overrides, +}); + +describe('getBoundingBox', () => { + it('should scale face coordinates to display dimensions', () => { + const face = makeFace(); + const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 }; + + const boxes = getBoundingBox([face], metrics); + + expect(boxes).toHaveLength(1); + expect(boxes[0]).toEqual({ + id: 'face-1', + top: Math.round(600 * (750 / 3000)), + left: Math.round(800 * (1000 / 4000)), + width: Math.round(800 * (2000 / 4000) - 800 * (1000 / 4000)), + height: Math.round(600 * (1500 / 3000) - 600 * (750 / 3000)), + }); + }); + + it('should apply offsets for letterboxed display', () => { + const face = makeFace({ + imageWidth: 1000, + imageHeight: 1000, + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1000, + boundingBoxY2: 1000, + }); + const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 600, offsetX: 100, offsetY: 0 }; + + const boxes = getBoundingBox([face], metrics); + + expect(boxes[0]).toEqual({ + id: 'face-1', + top: 0, + left: 100, + width: 600, + height: 600, + }); + }); + + it('should handle zoom by pre-scaled metrics', () => { + const face = makeFace({ + imageWidth: 1000, + imageHeight: 1000, + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 500, + boundingBoxY2: 500, + }); + const metrics: ContentMetrics = { + contentWidth: 1600, + contentHeight: 1200, + offsetX: -200, + offsetY: -100, + }; + + const boxes = getBoundingBox([face], metrics); + + expect(boxes[0]).toEqual({ + id: 'face-1', + top: -100, + left: -200, + width: 800, + height: 600, + }); + }); + + it('should return empty array for empty faces', () => { + const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 }; + expect(getBoundingBox([], metrics)).toEqual([]); + }); + + it('should handle multiple faces', () => { + const faces = [ + makeFace({ id: 'face-1', boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1000, boundingBoxY2: 1000 }), + makeFace({ id: 'face-2', boundingBoxX1: 2000, boundingBoxY1: 1500, boundingBoxX2: 3000, boundingBoxY2: 2500 }), + ]; + const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 }; + + const boxes = getBoundingBox(faces, metrics); + + expect(boxes).toHaveLength(2); + expect(boxes[0].left).toBeLessThan(boxes[1].left); + }); +}); diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index 863a3e9e88..b8fb8973e6 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -1,74 +1,39 @@ import type { Faces } from '$lib/stores/people.store'; import { getAssetMediaUrl } from '$lib/utils'; +import type { ContentMetrics } from '$lib/utils/container-utils'; import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk'; -import type { ZoomImageWheelState } from '@zoom-image/core'; -const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => { - const ratio = img.naturalWidth / img.naturalHeight; - let width = img.height * ratio; - let height = img.height; - if (width > img.width) { - width = img.width; - height = img.width / ratio; - } - return { width, height }; -}; - -export interface boundingBox { +export interface BoundingBox { + id: string; top: number; left: number; width: number; height: number; } -export const getBoundingBox = ( - faces: Faces[], - zoom: ZoomImageWheelState, - photoViewer: HTMLImageElement | undefined, -): boundingBox[] => { - const boxes: boundingBox[] = []; - - if (!photoViewer) { - return boxes; - } - const clientHeight = photoViewer.clientHeight; - const clientWidth = photoViewer.clientWidth; - - const { width, height } = getContainedSize(photoViewer); +export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): BoundingBox[] => { + const boxes: BoundingBox[] = []; for (const face of faces) { - /* - * - * Create the coordinates of the box based on the displayed image. - * The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer. - * - */ + const scaleX = metrics.contentWidth / face.imageWidth; + const scaleY = metrics.contentHeight / face.imageHeight; + const coordinates = { - x1: - (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 + - ((clientWidth - width) / 2) * zoom.currentZoom + - zoom.currentPositionX, - x2: - (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 + - ((clientWidth - width) / 2) * zoom.currentZoom + - zoom.currentPositionX, - y1: - (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 + - ((clientHeight - height) / 2) * zoom.currentZoom + - zoom.currentPositionY, - y2: - (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 + - ((clientHeight - height) / 2) * zoom.currentZoom + - zoom.currentPositionY, + x1: scaleX * face.boundingBoxX1 + metrics.offsetX, + x2: scaleX * face.boundingBoxX2 + metrics.offsetX, + y1: scaleY * face.boundingBoxY1 + metrics.offsetY, + y2: scaleY * face.boundingBoxY2 + metrics.offsetY, }; boxes.push({ + id: face.id, top: Math.round(coordinates.y1), left: Math.round(coordinates.x1), width: Math.round(coordinates.x2 - coordinates.x1), height: Math.round(coordinates.y2 - coordinates.y1), }); } + return boxes; }; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index deccdd7d6e..d7dc5d6aa4 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -170,6 +170,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): return { id: assetResponse.id, ownerId: assetResponse.ownerId, + tags: assetResponse.tags?.map((tag) => tag.id), ratio, thumbhash: assetResponse.thumbhash, localDateTime, diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 44a0c5e678..e354d2c1fc 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -30,6 +30,7 @@ import { AlbumPageViewMode } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; @@ -192,12 +193,13 @@ const updateThumbnail = async (assetId: string) => { try { - await updateAlbumInfo({ + const response = await updateAlbumInfo({ id: album.id, updateAlbumDto: { albumThumbnailAssetId: assetId, }, }); + eventManager.emit('AlbumUpdate', response); toastManager.success($t('album_cover_updated')); } catch (error) { handleError(error, $t('errors.unable_to_update_album_cover')); diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 011f08b787..69d18d1bd5 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,19 +1,18 @@ {#if featureFlagsManager.value.map} -
- {#await import('$lib/components/shared-components/map/map.svelte')} - {#await delay(timeToLoadTheMap) then} - -
- -
+
+
+ {#await import('$lib/components/shared-components/map/map.svelte')} + {#await delay(timeToLoadTheMap) then} + +
+ +
+ {/await} + {:then { default: Map }} + {/await} - {:then { default: Map }} - - {/await} +
+ + {#if isTimelinePanelVisible && selectedClusterBBox} +
+ +
+ {/if}
- {#if $showAssetViewer && assetCursor.current} + {#if $showAssetViewer} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} - onRandom={navigateRandom} + cursor={{ current: $viewingAsset }} + showNavigation={false} onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index dd2080a831..31a991fa8f 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -61,8 +61,6 @@ return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate); }); - const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id)); - const handleEscape = () => { if ($showAssetViewer) { return; @@ -135,7 +133,7 @@ - {#if isAllUserOwned} + {#if assetInteraction.isAllUserOwned} timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index d43cdcd5bb..701bc0ff59 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -25,7 +25,7 @@ import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { lang, locale } from '$lib/stores/preferences.store'; - import { preferences, user } from '$lib/stores/user.store'; + import { preferences } from '$lib/stores/user.store'; import { handlePromiseError } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { parseUtcDate } from '$lib/utils/date-time'; @@ -69,10 +69,6 @@ let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); - const isAllUserOwned = $derived( - $user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id), - ); - $effect(() => { // we want this to *only* be reactive on `terms` // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -276,6 +272,8 @@ {#await getTagNames(value) then tagNames} {tagNames} {/await} + {:else if searchKey === 'rating'} + {$t('rating_count', { values: { count: value ?? 0 } })} {:else if value === null || value === ''} {$t('unknown')} {:else} @@ -324,7 +322,7 @@
{#if assetInteraction.selectionActive} -
+
cancelMultiselect(assetInteraction)} @@ -342,7 +340,7 @@ onclick={handleSelectAll} /> - {#if isAllUserOwned} + {#if assetInteraction.isAllUserOwned} { @@ -362,10 +360,8 @@ - {#if assetInteraction.isAllUserOwned} - - {/if} - {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} + + {#if $preferences.tags.enabled} {/if} @@ -380,7 +376,7 @@
{:else} -
+
goto(previousRoute)} backIcon={mdiArrowLeft}>
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d1ed7d9832..046d5ce068 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -4,7 +4,7 @@ import { shortcut } from '$lib/actions/shortcut'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte'; - import AppleHeader from '$lib/components/shared-components/apple-header.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte'; import UploadPanel from '$lib/components/shared-components/upload-panel.svelte'; import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte'; @@ -20,6 +20,7 @@ import { copyToClipboard } from '$lib/utils'; import { maintenanceShouldRedirect } from '$lib/utils/maintenance'; import { isAssetViewerRoute } from '$lib/utils/navigation'; + import { getServerConfig } from '@immich/sdk'; import { CommandPaletteDefaultProvider, TooltipProvider, @@ -32,6 +33,7 @@ import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; + import { get } from 'svelte/store'; import '../app.css'; interface Props { @@ -121,20 +123,19 @@ if (maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) { modalManager.show(ServerRestartingModal, {}).catch((error) => console.error('Error [ServerRestartBox]:', error)); - - // we will be disconnected momentarily - // wait for reconnect then reload - let waiting = false; - websocketStore.connected.subscribe((connected) => { - if (!connected) { - waiting = true; - } else if (connected && waiting) { - location.reload(); - } - }); } }); + const onWebsocketConnect = async () => { + const isRestarting = get(serverRestarting); + if (isRestarting && maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) { + const { maintenanceMode } = await getServerConfig(); + if (maintenanceMode === isRestarting.isMaintenanceMode) { + location.reload(); + } + } + }; + const userCommands: ActionItem[] = [ { title: $t('theme'), @@ -183,14 +184,16 @@ const commands = $derived([...userCommands, ...adminCommands]); + + {page.data.meta?.title || 'Web'} - Immich - - + + {#if page.data.meta} diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 00dd588243..f3d2e80747 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -37,6 +37,7 @@ export const timelineAssetFactory = Sync.makeFactory({ id: Sync.each(() => faker.string.uuid()), ratio: Sync.each((i) => 0.2 + ((i * 0.618_034) % 3.8)), // deterministic random float between 0.2 and 4.0 ownerId: Sync.each(() => faker.string.uuid()), + tags: [], thumbhash: Sync.each(() => faker.string.alphanumeric(28)), localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), diff --git a/web/src/test-data/setup.ts b/web/src/test-data/setup.ts index 7a94c54338..b3e6a094a8 100644 --- a/web/src/test-data/setup.ts +++ b/web/src/test-data/setup.ts @@ -3,19 +3,23 @@ import { init } from 'svelte-i18n'; beforeAll(async () => { await init({ fallbackLocale: 'dev' }); - Element.prototype.animate = vi.fn().mockImplementation(() => ({ cancel: () => {}, finished: Promise.resolve() })); + Element.prototype.animate = vi.fn().mockImplementation(function () { + return { cancel: () => {}, finished: Promise.resolve() }; + }); }); Object.defineProperty(globalThis, 'matchMedia', { writable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + value: vi.fn().mockImplementation(function (query) { + return { + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), }); vi.mock('$env/dynamic/public', () => { diff --git a/web/static/manifest.json b/web/static/manifest.json index 8e8c3b9430..bde00b3c5f 100644 --- a/web/static/manifest.json +++ b/web/static/manifest.json @@ -1,6 +1,7 @@ { "background_color": "#ffffff", "description": "Self-hosted photo and video backup solution directly from your mobile phone.", + "dir": "auto", "display": "standalone", "icons": [ { @@ -9,12 +10,6 @@ "src": "manifest-icon-192.maskable.png", "type": "image/png" }, - { - "purpose": "maskable", - "sizes": "192x192", - "src": "manifest-icon-192.maskable.png", - "type": "image/png" - }, { "purpose": "any", "sizes": "512x512", @@ -31,5 +26,19 @@ "lang": "en", "name": "Immich", "short_name": "Immich", + "shortcuts": [ + { + "name": "Photos", + "url": "/photos" + }, + { + "name": "Albums", + "url": "/albums" + }, + { + "name": "Map", + "url": "/map" + } + ], "start_url": "/" } diff --git a/web/vite.config.ts b/web/vite.config.ts index 69e1d7152f..a30f2b4103 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ entries: ['src/**/*.{svelte,ts,html}'], }, test: { + name: 'web:unit', include: ['src/**/*.{test,spec}.{js,ts}'], globals: true, environment: 'happy-dom',