diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index b3411351a3..d9ac98aa2d 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -35,22 +35,21 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: yshavit/mdq:0.8.0@sha256:c69224d34224a0043d9a3ee46679ba4a2a25afaac445f293d92afe13cd47fcea + image: yshavit/mdq:0.9.0@sha256:4399483ca857fb1a7ed28a596f754c7373e358647de31ce14b79a27c91e1e35e outputs: - json: ${{ steps.get_checkbox.outputs.json }} + checked: ${{ steps.get_checkbox.outputs.checked }} steps: - id: get_checkbox env: BODY: ${{ needs.get_body.outputs.body }} - # TODO: We should detect if the checkbox is missing entirely and also close_and_comment in that case. run: | - JSON=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes') - echo "json=$JSON" >> $GITHUB_OUTPUT + CHECKED=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes' | jq '.items[0].list[0].checked // false') + echo "checked=$CHECKED" >> $GITHUB_OUTPUT close_and_comment: runs-on: ubuntu-latest needs: [get_checkbox_json, should_run] - if: ${{ needs.should_run.outputs.should_run == 'true' && !fromJSON(needs.get_checkbox_json.outputs.json).items[0].list[0].checked }} + if: ${{ needs.should_run.outputs.should_run == 'true' && needs.get_checkbox_json.outputs.checked != 'true' }} permissions: issues: write discussions: write diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fac8afd8ae..644dc26854 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 + uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -63,7 +63,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@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 + uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0 # â„šī¸ 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 @@ -76,6 +76,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 + uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index c05121ad70..b504b811e3 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -20,7 +20,7 @@ jobs: run: echo 'The triggering workflow did not succeed' && exit 1 - name: Get artifact id: get-artifact - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ @@ -38,7 +38,7 @@ jobs: return { found: true, id: matchArtifact.id }; - name: Determine deploy parameters id: parameters - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: HEAD_SHA: ${{ github.event.workflow_run.head_sha }} with: @@ -114,7 +114,7 @@ jobs: - name: Load parameters id: parameters - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: PARAM_JSON: ${{ needs.checks.outputs.parameters }} with: @@ -125,7 +125,7 @@ jobs: core.setOutput("shouldDeploy", parameters.shouldDeploy); - name: Download artifact - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }} with: diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 4c7c57e4f0..14043772ee 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -45,7 +45,7 @@ jobs: message: 'chore: fix formatting' - name: Remove label - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 if: always() with: script: | diff --git a/.github/workflows/merge-translations.yml b/.github/workflows/merge-translations.yml index c4167efa8a..a7906358af 100644 --- a/.github/workflows/merge-translations.yml +++ b/.github/workflows/merge-translations.yml @@ -23,21 +23,24 @@ jobs: run: | set -euo pipefail - gh pr list --repo $GITHUB_REPOSITORY --author weblate --json number,mergeable | read PR + PR=$(gh pr list --repo $GITHUB_REPOSITORY --author weblate --json number,mergeable) echo "$PR" - echo "$PR" | jq ' + PR_NUMBER=$(echo "$PR" | jq ' if length == 1 then .[0].number else error("Expected exactly 1 entry, got \(length)") end - ' 2>&1 | read PR_NUMBER || exit 1 + ' 2>&1) || exit 1 echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT echo "Selected PR $PR_NUMBER" - echo "$PR" | jq -e '.[0].mergeable == "MERGEABLE"' || { echo "PR is not mergeable" ; exit 1 } + if ! echo "$PR" | jq -e '.[0].mergeable == "MERGEABLE"'; then + echo "PR is not mergeable" + exit 1 + fi - name: Generate a token id: generate_token @@ -50,22 +53,25 @@ jobs: env: WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} run: | - curl -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=true + curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=true - name: Commit translations env: WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} run: | - curl -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=commit - curl -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=push + curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=commit + curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=push - name: Merge PR + id: merge_pr env: GH_TOKEN: ${{ steps.generate_token.outputs.token }} PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }} run: | - gh api -X POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" --field event='APPROVE' --field body='Automatically merging translations PR' \ - | jq '.id' | read REVIEW_ID + set -euo pipefail + + REVIEW_ID=$(gh api -X POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" --field event='APPROVE' --field body='Automatically merging translations PR' \ + | jq '.id') echo "REVIEW_ID=$REVIEW_ID" >> $GITHUB_OUTPUT gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash @@ -75,8 +81,11 @@ jobs: PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }} REVIEW_ID: ${{ steps.merge_pr.outputs.REVIEW_ID }} run: | + # So we clean up no matter what + set +e + for i in {1..10}; do - if gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json merged | jq -e '.merged == true'; then + if gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state | jq -e '.state == "MERGED"'; then echo "PR merged" exit 0 else @@ -93,4 +102,4 @@ jobs: env: WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} run: | - curl -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=false + curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=false diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml index 3ab9fd267f..1d9a0060ad 100644 --- a/.github/workflows/preview-label.yaml +++ b/.github/workflows/preview-label.yaml @@ -24,7 +24,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | github.rest.issues.removeLabel({ diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index d9b8c1fc0d..bb1e3240f3 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -129,7 +129,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 + uses: github/codeql-action/upload-sarif@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0 with: sarif_file: results.sarif category: zizmor diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3d2c9b0dc..c3c356d6e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -594,7 +594,7 @@ jobs: contents: read services: postgres: - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:ec713143dca1a426eba2e03707c319e2ec3cc9d304ef767f777f8e297dee820c + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:4f7ee144d4738ad02f6d9376defed7a767b748d185d47eba241578c26a63064b env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres diff --git a/.gitignore b/.gitignore index af85d96c02..25731cc2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ mobile/ios/fastlane/report.xml vite.config.js.timestamp-* .pnpm-store +.devcontainer/library +.devcontainer/.env* diff --git a/cli/package.json b/cli/package.json index f67d6bc212..39536d88fa 100644 --- a/cli/package.json +++ b/cli/package.json @@ -13,7 +13,6 @@ "cli" ], "devDependencies": { - "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@types/byte-size": "^8.1.0", diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index fe9e6d7bb4..189268f2fc 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -143,13 +143,13 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 + image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571 healthcheck: test: redis-cli ping || exit 1 database: container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0 env_file: - .env environment: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 7c658de336..f7d1f564cf 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,14 +56,14 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 + image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571 healthcheck: test: redis-cli ping || exit 1 restart: always database: container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0 env_file: - .env environment: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 052ae8b334..c401d4cfc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,14 +49,14 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 + image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571 healthcheck: test: redis-cli ping || exit 1 restart: always database: container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} diff --git a/docs/docs/features/monitoring.md b/docs/docs/features/monitoring.md index 64377ec073..c80f66902b 100644 --- a/docs/docs/features/monitoring.md +++ b/docs/docs/features/monitoring.md @@ -66,7 +66,7 @@ The provided file is just a starting point. There are a ton of ways to configure After bringing down the containers with `docker compose down` and back up with `docker compose up -d`, a Prometheus instance will now collect metrics from the immich server and microservices containers. Note that we didn't need to expose any new ports for these containers - the communication is handled in the internal Docker network. :::note -To see exactly what metrics are made available, you can additionally add `8081:8081` to the server container's ports and `8082:8082` to the microservices container's ports. +To see exactly what metrics are made available, you can additionally add `8081:8081` (API metrics) and `8082:8082` (microservices metrics) to the immich_server container's ports. Visiting the `/metrics` endpoint for these services will show the same raw data that Prometheus collects. To configure these ports see [`IMMICH_API_METRICS_PORT` & `IMMICH_MICROSERVICES_METRICS_PORT`](/docs/install/environment-variables/#general). ::: diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 267e7bf2ad..1a5c2ed193 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -147,7 +147,10 @@ SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config'; ### File properties ```sql title="Without thumbnails" -SELECT * FROM "asset" WHERE "asset"."previewPath" IS NULL OR "asset"."thumbnailPath" IS NULL; +SELECT * FROM "asset" +WHERE (NOT EXISTS (SELECT 1 FROM "asset_file" WHERE "asset"."id" = "asset_file"."assetId" AND "asset_file"."type" = 'thumbnail') + OR NOT EXISTS (SELECT 1 FROM "asset_file" WHERE "asset"."id" = "asset_file"."assetId" AND "asset_file"."type" = 'preview')) +AND "asset"."visibility" = 'timeline'; ``` ```sql title="Failed file movements" diff --git a/docs/package.json b/docs/package.json index fdf1da447c..1a1dbcf84c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -24,8 +24,6 @@ "@mdi/react": "^1.6.1", "@mdx-js/react": "^3.0.0", "autoprefixer": "^10.4.17", - "classnames": "^2.3.2", - "clsx": "^2.0.0", "docusaurus-lunr-search": "^3.3.2", "docusaurus-preset-openapi": "^0.7.5", "lunr": "^2.3.9", diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 46e28b3b76..930cff66c1 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -105,6 +105,11 @@ const projects: CommunityProjectProps[] = [ description: 'Speed up your machine learning by load balancing your requests to multiple computers', url: 'https://github.com/apetersson/immich_ml_balancer', }, + { + title: 'Immich Drop Uploader', + description: 'A tiny, zero-login web app for collecting photos/videos from anyone into your Immich server.', + url: 'https://github.com/Nasogaa/immich-drop', + }, ]; function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { diff --git a/e2e/package.json b/e2e/package.json index 2cf1b79f86..d309067af8 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -19,7 +19,6 @@ "author": "", "license": "GNU Affero General Public License version 3", "devDependencies": { - "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", @@ -31,7 +30,6 @@ "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "^3.0.0", "eslint": "^9.14.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 9c8b893075..5c30ff5cbe 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1466,10 +1466,10 @@ describe('/asset', () => { expectedDate: '2023-04-04T04:00:00.000Z', }, { - name: 'CreateDate when DateTimeOriginal missing', + name: 'CreationDate when DateTimeOriginal missing', exifData: { - CreateDate: '2023:05:05 05:00:00', // TESTABLE - CreationDate: '2023:07:07 07:00:00', // TESTABLE + CreationDate: '2023:05:05 05:00:00', // TESTABLE + CreateDate: '2023:07:07 07:00:00', // TESTABLE GPSDateTime: '2023:10:10 10:00:00', // TESTABLE }, expectedDate: '2023-05-05T05:00:00.000Z', diff --git a/i18n/en.json b/i18n/en.json index 9aa33d377d..30c652ee3d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -610,8 +610,6 @@ "backup_setting_subtitle": "Manage background and foreground upload settings", "backup_settings_subtitle": "Manage upload settings", "backward": "Backward", - "beta_sync": "Beta Sync Status", - "beta_sync_subtitle": "Manage the new sync system", "biometric_auth_enabled": "Biometric authentication enabled", "biometric_locked_out": "You are locked out of biometric authentication", "biometric_no_options": "No biometric options available", @@ -1089,10 +1087,7 @@ "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", "general": "General", - "geolocation_instruction_all_have_location": "All assets for this date already have location data. Try showing all assets or select a different date", "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", - "geolocation_instruction_no_date": "Select a date to manage location data for photos and videos from that day", - "geolocation_instruction_no_photos": "No photos or videos found for this date. Select a different date to show them", "get_help": "Get Help", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "getting_started": "Getting Started", @@ -1534,7 +1529,7 @@ "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", "profile_drawer_github": "GitHub", - "profile_drawer_readonly_mode": "Read-only mode enabled. Double-tap the user avatar icon to exit.", + "profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.", "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", "profile_image_of_user": "Profile image of {user}", @@ -1659,6 +1654,7 @@ "restore_user": "Restore user", "restored_asset": "Restored asset", "resume": "Resume", + "resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}", "retry_upload": "Retry upload", "review_duplicates": "Review duplicates", "review_large_files": "Review large files", @@ -1866,10 +1862,8 @@ "shift_to_permanent_delete": "press ⇧ to permanently delete asset", "show_album_options": "Show album options", "show_albums": "Show albums", - "show_all_assets": "Show all assets", "show_all_people": "Show all people", "show_and_hide_people": "Show & hide people", - "show_assets_without_location": "Show assets without location", "show_file_location": "Show file location", "show_gallery": "Show gallery", "show_hidden_people": "Show hidden people", @@ -1940,6 +1934,8 @@ "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", "sync_local": "Sync Local", "sync_remote": "Sync Remote", + "sync_status": "Sync Status", + "sync_status_subtitle": "View and manage the sync system", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", "tag": "Tag", "tag_assets": "Tag assets", @@ -1999,6 +1995,7 @@ "trash_page_select_assets_btn": "Select assets", "trash_page_title": "Trash ({count})", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", + "troubleshoot": "Troubleshoot", "type": "Type", "unable_to_change_pin_code": "Unable to change PIN code", "unable_to_setup_pin_code": "Unable to setup PIN code", @@ -2054,7 +2051,6 @@ "use_biometric": "Use biometric", "use_current_connection": "use current connection", "use_custom_date_range": "Use custom date range instead", - "use_this_location": "Click to use location", "user": "User", "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index ddc62e722b..12b4bfd952 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:c642d5dfaf9115a12086785f23008558ae2e13bcd0c4794536340bcb777a4381 AS builder-cpu +FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \ uv pip install /opt/onnxruntime_rocm-*.whl; \ fi -FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 -FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-openvino +FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 6dcd81774a..17ead45f01 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -76,7 +76,8 @@ enum StoreKey { betaTimeline._(1002), enableBackup._(1003), useWifiForUploadVideos._(1004), - useWifiForUploadPhotos._(1005); + useWifiForUploadPhotos._(1005), + needBetaMigration._(1006); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index df34a41e54..875dc80702 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -1,3 +1,4 @@ +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/exif.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; @@ -27,6 +28,14 @@ class AssetService { return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id); } + Future> getLocalAssetsByChecksum(String checksum) { + return _localAssetRepository.getByChecksum(checksum); + } + + Future getRemoteAssetByChecksum(String checksum) { + return _remoteAssetRepository.getByChecksum(checksum); + } + Future getRemoteAsset(String id) { return _remoteAssetRepository.get(id); } @@ -89,4 +98,8 @@ class AssetService { Future getLocalHashedCount() { return _localAssetRepository.getHashedCount(); } + + Future> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) { + return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection); + } } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index b3d97e0938..18f07dc021 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -5,7 +5,6 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart'; @@ -24,6 +23,7 @@ import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; +import 'package:worker_manager/worker_manager.dart'; class BackgroundWorkerFgService { final BackgroundWorkerFgHostApi _foregroundHostApi; @@ -42,8 +42,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final Drift _drift; final DriftLogger _driftLogger; final BackgroundWorkerBgHostApi _backgroundHostApi; - final Logger _logger = Logger('BackgroundUploadBgService'); - late final IsolateLockManager _lockManager; + final Logger _logger = Logger('BackgroundWorkerBgService'); bool _isCleanedUp = false; @@ -59,7 +58,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { driftProvider.overrideWith(driftOverride(drift)), ], ); - _lockManager = IsolateLockManager(onCloseRequest: _cleanup); BackgroundWorkerFlutterApi.setUp(this); } @@ -67,41 +65,30 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - await loadTranslations(); HttpSSLOptions.apply(applyNative: false); - await _ref.read(authServiceProvider).setOpenApiServiceEndpoint(); - // Initialize the file downloader - await FileDownloader().configure( - globalConfig: [ - // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 - (Config.holdingQueue, (6, 6, 3)), - // On Android, if files are larger than 256MB, run in foreground service - (Config.runInForegroundIfFileLargerThan, 256), - ], - ); - await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); - await FileDownloader().trackTasks(); + await Future.wait([ + loadTranslations(), + workerManager.init(dynamicSpawning: true), + _ref.read(authServiceProvider).setOpenApiServiceEndpoint(), + // Initialize the file downloader + FileDownloader().configure( + globalConfig: [ + // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 + (Config.holdingQueue, (6, 6, 3)), + // On Android, if files are larger than 256MB, run in foreground service + (Config.runInForegroundIfFileLargerThan, 256), + ], + ), + FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false), + FileDownloader().trackTasks(), + _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess(), + ]); + configureFileDownloaderNotifications(); - await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess(); - // Notify the host that the background upload service has been initialized and is ready to use - debugPrint("Acquiring background worker lock"); - if (await _lockManager.acquireLock().timeout( - const Duration(seconds: 5), - onTimeout: () { - _lockManager.cancel(); - return false; - }, - )) { - _logger.info("Acquired background worker lock"); - await _backgroundHostApi.onInitialized(); - return; - } - - _logger.warning("Failed to acquire background worker lock"); - await _cleanup(); - await _backgroundHostApi.close(); + // Notify the host that the background worker service has been initialized and is ready to use + _backgroundHostApi.onInitialized(); } catch (error, stack) { _logger.severe("Failed to initialize background worker", error, stack); _backgroundHostApi.close(); @@ -170,6 +157,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _isCleanedUp = true; _logger.info("Cleaning up background worker"); final cleanupFutures = [ + workerManager.dispose(), _drift.close(), _driftLogger.close(), _ref.read(backgroundSyncProvider).cancel(), @@ -180,8 +168,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { cleanupFutures.add(_isar.close()); } _ref.dispose(); - _lockManager.releaseLock(); - await Future.wait(cleanupFutures); _logger.info("Background worker resources cleaned up"); } catch (error, stack) { @@ -190,52 +176,56 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } Future _handleBackup({bool processBulk = true}) async { - if (!_isBackupEnabled) { + if (!_isBackupEnabled || _isCleanedUp) { + _logger.info("[_handleBackup 1] Backup is disabled. Skipping backup routine"); return; } + _logger.info("[_handleBackup 2] Enqueuing assets for backup from the background service"); + final currentUser = _ref.read(currentUserProvider); if (currentUser == null) { + _logger.warning("[_handleBackup 3] No current user found. Skipping backup from background"); return; } if (processBulk) { + _logger.info("[_handleBackup 4] Resume backup from background"); return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); } final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id); if (activeTask.isNotEmpty) { + _logger.info("[_handleBackup 5] Resuming backup for active tasks from background"); await _ref.read(uploadServiceProvider).resumeBackup(); } else { + _logger.info("[_handleBackup 6] Starting serial backup for new tasks from background"); await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id); } } Future _syncAssets({Duration? hashTimeout}) async { - final futures = >[]; + await _ref.read(backgroundSyncProvider).syncLocal(); + if (_isCleanedUp) { + return; + } - final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async { - if (_isCleanedUp) { - return; - } + await _ref.read(backgroundSyncProvider).syncRemote(); + if (_isCleanedUp) { + return; + } - var hashFuture = _ref.read(backgroundSyncProvider).hashAssets(); - if (hashTimeout != null) { - hashFuture = hashFuture.timeout( - hashTimeout, - onTimeout: () { - // Consume cancellation errors as we want to continue processing - }, - ); - } + var hashFuture = _ref.read(backgroundSyncProvider).hashAssets(); + if (hashTimeout != null) { + hashFuture = hashFuture.timeout( + hashTimeout, + onTimeout: () { + // Consume cancellation errors as we want to continue processing + }, + ); + } - return hashFuture; - }); - - futures.add(localSyncFuture); - futures.add(_ref.read(backgroundSyncProvider).syncRemote()); - - await Future.wait(futures); + await hashFuture; } } diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart index 3347134ae6..762d5db3b9 100644 --- a/mobile/lib/domain/services/store.service.dart +++ b/mobile/lib/domain/services/store.service.dart @@ -90,7 +90,7 @@ class StoreService { _cache.clear(); } - bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? false; + bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? true; } class StoreKeyNotFoundException implements Exception { diff --git a/mobile/lib/domain/services/sync_linked_album.service.dart b/mobile/lib/domain/services/sync_linked_album.service.dart index 37e52e6c16..2c445f3aca 100644 --- a/mobile/lib/domain/services/sync_linked_album.service.dart +++ b/mobile/lib/domain/services/sync_linked_album.service.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:logging/logging.dart'; final syncLinkedAlbumServiceProvider = Provider( (ref) => SyncLinkedAlbumService( @@ -19,7 +20,9 @@ class SyncLinkedAlbumService { final DriftRemoteAlbumRepository _remoteAlbumRepository; final DriftAlbumApiRepository _albumApiRepository; - const SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository); + SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository); + + final _log = Logger("SyncLinkedAlbumService"); Future syncLinkedAlbums(String userId) async { final selectedAlbums = await _localAlbumRepository.getBackupAlbums(); @@ -48,8 +51,12 @@ class SyncLinkedAlbumService { } Future manageLinkedAlbums(List localAlbums, String ownerId) async { - for (final album in localAlbums) { - await _processLocalAlbum(album, ownerId); + try { + for (final album in localAlbums) { + await _processLocalAlbum(album, ownerId); + } + } catch (error, stackTrace) { + _log.severe("Error managing linked albums", error, stackTrace); } } diff --git a/mobile/lib/domain/utils/isolate_lock_manager.dart b/mobile/lib/domain/utils/isolate_lock_manager.dart deleted file mode 100644 index 37de649204..0000000000 --- a/mobile/lib/domain/utils/isolate_lock_manager.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'dart:isolate'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:logging/logging.dart'; - -const String kIsolateLockManagerPort = "immich://isolate_mutex"; - -enum _LockStatus { active, released } - -class _IsolateRequest { - const _IsolateRequest(); -} - -class _HeartbeatRequest extends _IsolateRequest { - // Port for the receiver to send replies back - final SendPort sendPort; - - const _HeartbeatRequest(this.sendPort); - - Map toJson() { - return {'type': 'heartbeat', 'sendPort': sendPort}; - } -} - -class _CloseRequest extends _IsolateRequest { - const _CloseRequest(); - - Map toJson() { - return {'type': 'close'}; - } -} - -class _IsolateResponse { - const _IsolateResponse(); -} - -class _HeartbeatResponse extends _IsolateResponse { - final _LockStatus status; - - const _HeartbeatResponse(this.status); - - Map toJson() { - return {'type': 'heartbeat', 'status': status.index}; - } -} - -typedef OnCloseLockHolderRequest = void Function(); - -class IsolateLockManager { - final String _portName; - bool _hasLock = false; - ReceivePort? _receivePort; - final OnCloseLockHolderRequest? _onCloseRequest; - final Set _waitingIsolates = {}; - // Token object - a new one is created for each acquisition attempt - Object? _currentAcquisitionToken; - - IsolateLockManager({String? portName, OnCloseLockHolderRequest? onCloseRequest}) - : _portName = portName ?? kIsolateLockManagerPort, - _onCloseRequest = onCloseRequest; - - Future acquireLock() async { - if (_hasLock) { - Logger('BackgroundWorkerLockManager').warning("WARNING: [acquireLock] called more than once"); - return true; - } - - // Create a new token - this invalidates any previous attempt - final token = _currentAcquisitionToken = Object(); - - final ReceivePort rp = _receivePort = ReceivePort(_portName); - final SendPort sp = rp.sendPort; - - while (!IsolateNameServer.registerPortWithName(sp, _portName)) { - // This attempt was superseded by a newer one in the same isolate - if (_currentAcquisitionToken != token) { - return false; - } - - await _lockReleasedByHolder(token); - } - - _hasLock = true; - rp.listen(_onRequest); - return true; - } - - Future _lockReleasedByHolder(Object token) async { - SendPort? holder = IsolateNameServer.lookupPortByName(_portName); - debugPrint("Found lock holder: $holder"); - if (holder == null) { - // No holder, try and acquire lock - return; - } - - final ReceivePort tempRp = ReceivePort(); - final SendPort tempSp = tempRp.sendPort; - final bs = tempRp.asBroadcastStream(); - - try { - while (true) { - // Send a heartbeat request with the send port to receive reply from the holder - - debugPrint("Sending heartbeat request to lock holder"); - holder.send(_HeartbeatRequest(tempSp).toJson()); - dynamic answer = await bs.first.timeout(const Duration(seconds: 3), onTimeout: () => null); - - debugPrint("Received heartbeat response from lock holder: $answer"); - // This attempt was superseded by a newer one in the same isolate - if (_currentAcquisitionToken != token) { - break; - } - - if (answer == null) { - // Holder failed, most likely killed without calling releaseLock - // Check if a different waiting isolate took the lock - if (holder == IsolateNameServer.lookupPortByName(_portName)) { - // No, remove the stale lock - IsolateNameServer.removePortNameMapping(_portName); - } - break; - } - - // Unknown message type received for heartbeat request. Try again - _IsolateResponse? response = _parseResponse(answer); - if (response == null || response is! _HeartbeatResponse) { - break; - } - - if (response.status == _LockStatus.released) { - // Holder has released the lock - break; - } - - // If the _LockStatus is active, we check again if the task completed - // by sending a released messaged again, if not, send a new heartbeat again - - // Check if the holder completed its task after the heartbeat - answer = await bs.first.timeout( - const Duration(seconds: 3), - onTimeout: () => const _HeartbeatResponse(_LockStatus.active).toJson(), - ); - - response = _parseResponse(answer); - if (response is _HeartbeatResponse && response.status == _LockStatus.released) { - break; - } - } - } catch (e) { - // Timeout or error - } finally { - tempRp.close(); - } - return; - } - - _IsolateRequest? _parseRequest(dynamic msg) { - if (msg is! Map) { - return null; - } - - return switch (msg['type']) { - 'heartbeat' => _HeartbeatRequest(msg['sendPort']), - 'close' => const _CloseRequest(), - _ => null, - }; - } - - _IsolateResponse? _parseResponse(dynamic msg) { - if (msg is! Map) { - return null; - } - - return switch (msg['type']) { - 'heartbeat' => _HeartbeatResponse(_LockStatus.values[msg['status']]), - _ => null, - }; - } - - // Executed in the isolate with the lock - void _onRequest(dynamic msg) { - final request = _parseRequest(msg); - if (request == null) { - return; - } - - if (request is _HeartbeatRequest) { - // Add the send port to the list of waiting isolates - _waitingIsolates.add(request.sendPort); - request.sendPort.send(const _HeartbeatResponse(_LockStatus.active).toJson()); - return; - } - - if (request is _CloseRequest) { - _onCloseRequest?.call(); - return; - } - } - - void releaseLock() { - if (_hasLock) { - IsolateNameServer.removePortNameMapping(_portName); - - // Notify waiting isolates - for (final port in _waitingIsolates) { - port.send(const _HeartbeatResponse(_LockStatus.released).toJson()); - } - _waitingIsolates.clear(); - - _hasLock = false; - } - - _receivePort?.close(); - _receivePort = null; - } - - void cancel() { - if (_hasLock) { - return; - } - - debugPrint("Cancelling ongoing acquire lock attempts"); - // Create a new token to invalidate ongoing acquire lock attempts - _currentAcquisitionToken = Object(); - } - - void requestHolderToClose() { - if (_hasLock) { - return; - } - - IsolateNameServer.lookupPortByName(_portName)?.send(const _CloseRequest().toJson()); - } -} diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index 057c7a7bf6..1e9f69147c 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -4,7 +4,6 @@ import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -138,22 +137,4 @@ class DriftBackupRepository extends DriftDatabaseRepository { return query.map((localAsset) => localAsset.toDto()).get(); } - - FutureOr> getSourceAlbums(String localAssetId) { - final query = _db.localAlbumEntity.select() - ..where( - (lae) => - existsQuery( - _db.localAlbumAssetEntity.selectOnly() - ..addColumns([_db.localAlbumAssetEntity.albumId]) - ..where( - _db.localAlbumAssetEntity.albumId.equalsExp(lae.id) & - _db.localAlbumAssetEntity.assetId.equals(localAssetId), - ), - ) & - lae.backupSelection.equalsValue(BackupSelection.selected), - ) - ..orderBy([(lae) => OrderingTerm.asc(lae.name)]); - return query.map((localAlbum) => localAlbum.toDto()).get(); - } } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 5865447064..05c8e06678 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,6 +1,8 @@ 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/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -26,6 +28,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { Future get(String id) => _assetSelectable(id).getSingleOrNull(); + Future> getByChecksum(String checksum) { + final query = _db.localAssetEntity.select()..where((lae) => lae.checksum.equals(checksum)); + + return query.map((row) => row.toDto()).get(); + } + Stream watch(String id) => _assetSelectable(id).watchSingleOrNull(); Future updateHashes(Iterable hashes) { @@ -69,4 +77,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { Future getHashedCount() { return _db.managers.localAssetEntity.filter((e) => e.checksum.isNull().not()).count(); } + + Future> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) { + final query = _db.localAlbumEntity.select() + ..where( + (lae) => existsQuery( + _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.albumId]) + ..where( + _db.localAlbumAssetEntity.albumId.equalsExp(lae.id) & + _db.localAlbumAssetEntity.assetId.equals(localAssetId), + ), + ), + ) + ..orderBy([(lae) => OrderingTerm.asc(lae.name)]); + if (backupSelection != null) { + query.where((lae) => lae.backupSelection.equalsValue(backupSelection)); + } + return query.map((localAlbum) => localAlbum.toDto()).get(); + } } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 3ed7dddfe8..01aa10c7ad 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -55,6 +55,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository { return _assetSelectable(id).getSingleOrNull(); } + Future getByChecksum(String checksum) { + final query = _db.remoteAssetEntity.select()..where((row) => row.checksum.equals(checksum)); + + return query.map((row) => row.toDto()).getSingleOrNull(); + } + Future> getStackChildren(RemoteAsset asset) { if (asset.stackId == null) { return Future.value([]); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 9066c5bfc7..4f74c30e3b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -17,9 +17,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; -import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; @@ -205,9 +205,9 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve // needs to be delayed so that EasyLocalization is working if (Store.isBetaTimelineEnabled) { ref.read(backgroundServiceProvider).disableService(); - ref.read(driftBackgroundUploadFgService).enable(); + ref.read(backgroundWorkerFgServiceProvider).enable(); } else { - ref.read(driftBackgroundUploadFgService).disable(); + ref.read(backgroundWorkerFgServiceProvider).disable(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); } }); diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index ffdba1fb71..47e18470ca 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,6 +15,7 @@ import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -41,49 +44,13 @@ class _ChangeExperiencePageState extends ConsumerState { Future _handleMigration() async { try { - if (widget.switchingToBeta) { - final assetNotifier = ref.read(assetProvider.notifier); - if (assetNotifier.mounted) { - assetNotifier.dispose(); - } - final albumNotifier = ref.read(albumProvider.notifier); - if (albumNotifier.mounted) { - albumNotifier.dispose(); - } - - // Cancel uploads - await Store.put(StoreKey.backgroundBackup, false); - ref - .read(backupProvider.notifier) - .configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {}); - ref.read(backupProvider.notifier).setAutoBackup(false); - ref.read(backupProvider.notifier).cancelBackup(); - ref.read(manualUploadProvider.notifier).cancelBackup(); - // Start listening to new websocket events - ref.read(websocketProvider.notifier).stopListenToOldEvents(); - ref.read(websocketProvider.notifier).startListeningToBetaEvents(); - - final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - - if (permission.isGranted) { - await ref.read(backgroundSyncProvider).syncLocal(full: true); - await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider)); - await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider)); - await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider)); - await ref.read(backgroundServiceProvider).disableService(); - } - } else { - await ref.read(backgroundSyncProvider).cancel(); - ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); - ref.read(websocketProvider.notifier).startListeningToOldEvents(); - ref.read(readonlyModeProvider.notifier).setReadonlyMode(false); - await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); - await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); - await ref.read(driftBackgroundUploadFgService).disable(); - } - - await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); - await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); + await _performMigrationLogic().timeout( + const Duration(minutes: 3), + onTimeout: () async { + await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); + await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); + }, + ); if (mounted) { setState(() { @@ -101,6 +68,52 @@ class _ChangeExperiencePageState extends ConsumerState { } } + Future _performMigrationLogic() async { + if (widget.switchingToBeta) { + final assetNotifier = ref.read(assetProvider.notifier); + if (assetNotifier.mounted) { + assetNotifier.dispose(); + } + final albumNotifier = ref.read(albumProvider.notifier); + if (albumNotifier.mounted) { + albumNotifier.dispose(); + } + + // Cancel uploads + await Store.put(StoreKey.backgroundBackup, false); + ref + .read(backupProvider.notifier) + .configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {}); + ref.read(backupProvider.notifier).setAutoBackup(false); + ref.read(backupProvider.notifier).cancelBackup(); + ref.read(manualUploadProvider.notifier).cancelBackup(); + // Start listening to new websocket events + ref.read(websocketProvider.notifier).stopListenToOldEvents(); + ref.read(websocketProvider.notifier).startListeningToBetaEvents(); + + final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); + + if (permission.isGranted) { + await ref.read(backgroundSyncProvider).syncLocal(full: true); + await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + await ref.read(backgroundServiceProvider).disableService(); + } + } else { + await ref.read(backgroundSyncProvider).cancel(); + ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); + ref.read(websocketProvider.notifier).startListeningToOldEvents(); + ref.read(readonlyModeProvider.notifier).setReadonlyMode(false); + await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); + await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + await ref.read(backgroundWorkerFgServiceProvider).disable(); + } + + await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); + await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 7bc8cd2b3a..014136ddb4 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_se import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; -import 'package:immich_mobile/widgets/settings/beta_sync_settings/beta_sync_settings.dart'; +import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; @@ -20,7 +20,7 @@ import 'package:immich_mobile/widgets/settings/preference_settings/preference_se import 'package:immich_mobile/widgets/settings/settings_card.dart'; enum SettingSection { - beta('beta_sync', Icons.sync_outlined, "beta_sync_subtitle"), + beta('sync_status', Icons.sync_outlined, "sync_status_subtitle"), advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), @@ -76,9 +76,9 @@ class _MobileLayout extends StatelessWidget { if (Store.isBetaTimelineEnabled) SettingsCard( icon: Icons.sync_outlined, - title: 'beta_sync'.tr(), - subtitle: 'beta_sync_subtitle'.tr(), - settingRoute: const BetaSyncSettingsRoute(), + title: 'sync_status'.tr(), + subtitle: 'sync_status_subtitle'.tr(), + settingRoute: const SyncStatusRoute(), ), ] : [ @@ -143,7 +143,7 @@ class _BetaLandscapeToggle extends HookWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ const SizedBox(height: 100, child: BetaTimelineListTile()), - if (Store.isBetaTimelineEnabled) const Expanded(child: BetaSyncSettings()), + if (Store.isBetaTimelineEnabled) const Expanded(child: SyncStatusAndActions()), ], ); } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 64db7daee6..0bedae4242 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; @@ -23,23 +22,14 @@ class SplashScreenPage extends StatefulHookConsumerWidget { class SplashScreenPageState extends ConsumerState { final log = Logger("SplashScreenPage"); - @override void initState() { super.initState(); - final lockManager = ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)); - - lockManager.requestHolderToClose(); - lockManager - .acquireLock() - .timeout(const Duration(seconds: 5)) - .whenComplete( - () => ref - .read(authProvider.notifier) - .setOpenApiServiceEndpoint() - .then(logConnectionInfo) - .whenComplete(() => resumeSession()), - ); + ref + .read(authProvider.notifier) + .setOpenApiServiceEndpoint() + .then(logConnectionInfo) + .whenComplete(() => resumeSession()); } void logConnectionInfo(String? endpoint) { @@ -58,11 +48,23 @@ class SplashScreenPageState extends ConsumerState { if (accessToken != null && serverUrl != null && endpoint != null) { final infoProvider = ref.read(serverInfoProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier); + final backgroundManager = ref.read(backgroundSyncProvider); + ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( - (a) { + (_) async { try { wsProvider.connect(); infoProvider.getServerInfo(); + + if (Store.isBetaTimelineEnabled) { + await backgroundManager.syncLocal(); + await backgroundManager.syncRemote(); + await backgroundManager.hashAssets(); + } + + if (Store.get(StoreKey.syncAlbums, false)) { + await backgroundManager.syncLinkedAlbum(); + } } catch (e) { log.severe('Failed establishing connection to the server: $e'); } @@ -80,7 +82,16 @@ class SplashScreenPageState extends ConsumerState { return; } + // clean install - change the default of the flag + // current install not using beta timeline if (context.router.current.name == SplashScreenRoute.name) { + final needBetaMigration = Store.get(StoreKey.needBetaMigration, false); + if (needBetaMigration) { + await Store.put(StoreKey.needBetaMigration, false); + context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]); + return; + } + context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()); } diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 8b68a8b9c0..b60fe1ddc1 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -7,8 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; @@ -17,11 +15,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/migration.dart'; @RoutePage() class TabShellPage extends ConsumerStatefulWidget { @@ -32,28 +26,6 @@ class TabShellPage extends ConsumerStatefulWidget { } class _TabShellPageState extends ConsumerState { - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - ref.read(websocketProvider.notifier).connect(); - - final isEnableBackup = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); - - await runNewSync(ref, full: true).then((_) async { - if (isEnableBackup) { - final currentUser = ref.read(currentUserProvider); - if (currentUser == null) { - return; - } - - await ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); - } - }); - }); - } - @override Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; diff --git a/mobile/lib/pages/settings/beta_sync_settings.page.dart b/mobile/lib/pages/settings/sync_status.page.dart similarity index 71% rename from mobile/lib/pages/settings/beta_sync_settings.page.dart rename to mobile/lib/pages/settings/sync_status.page.dart index 992557b7c6..d54ba89e5d 100644 --- a/mobile/lib/pages/settings/beta_sync_settings.page.dart +++ b/mobile/lib/pages/settings/sync_status.page.dart @@ -1,25 +1,25 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/widgets/settings/beta_sync_settings/beta_sync_settings.dart'; +import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; @RoutePage() -class BetaSyncSettingsPage extends StatelessWidget { - const BetaSyncSettingsPage({super.key}); +class SyncStatusPage extends StatelessWidget { + const SyncStatusPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( elevation: 0, - title: const Text("beta_sync").t(context: context), + title: const Text("sync_status").t(context: context), leading: IconButton( onPressed: () => context.maybePop(true), splashRadius: 24, icon: const Icon(Icons.arrow_back_ios_rounded), ), ), - body: const BetaSyncSettings(), + body: const SyncStatusAndActions(), ); } } diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart new file mode 100644 index 0000000000..1cd6bee67d --- /dev/null +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -0,0 +1,345 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; + +@RoutePage() +class AssetTroubleshootPage extends ConsumerWidget { + final BaseAsset asset; + + const AssetTroubleshootPage({super.key, required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text("Asset Troubleshoot")), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _AssetDetailsView(asset: asset), + ), + ), + ); + } +} + +class _AssetDetailsView extends ConsumerWidget { + final BaseAsset asset; + + const _AssetDetailsView({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AssetPropertiesSection(asset: asset), + const SizedBox(height: 16), + Text('Matching Assets', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + if (asset.checksum != null) ...[ + _LocalAssetsSection(asset: asset), + const SizedBox(height: 16), + _RemoteAssetSection(asset: asset), + ] else ...[ + const _PropertySectionCard( + title: 'Local Assets', + properties: [_PropertyItem(label: 'Status', value: 'No checksum available - cannot fetch local assets')], + ), + const SizedBox(height: 16), + const _PropertySectionCard( + title: 'Remote Assets', + properties: [_PropertyItem(label: 'Status', value: 'No checksum available - cannot fetch remote asset')], + ), + ], + ], + ); + } +} + +class _AssetPropertiesSection extends ConsumerStatefulWidget { + final BaseAsset asset; + + const _AssetPropertiesSection({required this.asset}); + + @override + ConsumerState createState() => _AssetPropertiesSectionState(); +} + +class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection> { + List<_PropertyItem> properties = []; + + @override + void initState() { + super.initState(); + _buildAssetProperties(widget.asset).whenComplete(() { + if (mounted) { + setState(() {}); + } + }); + } + + @override + Widget build(BuildContext context) { + final title = _getAssetTypeTitle(widget.asset); + + return _PropertySectionCard(title: title, properties: properties); + } + + Future _buildAssetProperties(BaseAsset asset) async { + _addCommonProperties(); + + if (asset is LocalAsset) { + await _addLocalAssetProperties(asset); + } else if (asset is RemoteAsset) { + await _addRemoteAssetProperties(asset); + } + } + + void _addCommonProperties() { + final asset = widget.asset; + properties.addAll([ + _PropertyItem(label: 'Name', value: asset.name), + _PropertyItem(label: 'Checksum', value: asset.checksum), + _PropertyItem(label: 'Type', value: asset.type.toString()), + _PropertyItem(label: 'Created At', value: asset.createdAt.toString()), + _PropertyItem(label: 'Updated At', value: asset.updatedAt.toString()), + _PropertyItem(label: 'Width', value: asset.width?.toString()), + _PropertyItem(label: 'Height', value: asset.height?.toString()), + _PropertyItem( + label: 'Duration', + value: asset.durationInSeconds != null ? '${asset.durationInSeconds} seconds' : null, + ), + _PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()), + _PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId), + ]); + } + + Future _addLocalAssetProperties(LocalAsset asset) async { + properties.insertAll(0, [ + _PropertyItem(label: 'Local ID', value: asset.id), + _PropertyItem(label: 'Remote ID', value: asset.remoteId), + ]); + + properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString())); + final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id); + properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', '))); + } + + Future _addRemoteAssetProperties(RemoteAsset asset) async { + properties.insertAll(0, [ + _PropertyItem(label: 'Remote ID', value: asset.id), + _PropertyItem(label: 'Local ID', value: asset.localId), + _PropertyItem(label: 'Owner ID', value: asset.ownerId), + ]); + + final additionalProps = <_PropertyItem>[ + _PropertyItem(label: 'Thumb Hash', value: asset.thumbHash), + _PropertyItem(label: 'Visibility', value: asset.visibility.toString()), + _PropertyItem(label: 'Stack ID', value: asset.stackId), + ]; + + properties.insertAll(4, additionalProps); + + final exif = await ref.read(assetServiceProvider).getExif(asset); + if (exif != null) { + _addExifProperties(exif); + } else { + properties.add(const _PropertyItem(label: 'EXIF', value: null)); + } + } + + void _addExifProperties(ExifInfo exif) { + properties.addAll([ + _PropertyItem( + label: 'File Size', + value: exif.fileSize != null ? '${(exif.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : null, + ), + _PropertyItem(label: 'Description', value: exif.description), + _PropertyItem(label: 'EXIF Width', value: exif.width?.toString()), + _PropertyItem(label: 'EXIF Height', value: exif.height?.toString()), + _PropertyItem(label: 'Date Taken', value: exif.dateTimeOriginal?.toString()), + _PropertyItem(label: 'Time Zone', value: exif.timeZone), + _PropertyItem(label: 'Camera Make', value: exif.make), + _PropertyItem(label: 'Camera Model', value: exif.model), + _PropertyItem(label: 'Lens', value: exif.lens), + _PropertyItem(label: 'F-Number', value: exif.f != null ? 'f/${exif.fNumber}' : null), + _PropertyItem(label: 'Focal Length', value: exif.mm != null ? '${exif.focalLength}mm' : null), + _PropertyItem(label: 'ISO', value: exif.iso?.toString()), + _PropertyItem(label: 'Exposure Time', value: exif.exposureTime.isNotEmpty ? exif.exposureTime : null), + _PropertyItem( + label: 'GPS Coordinates', + value: exif.hasCoordinates ? '${exif.latitude}, ${exif.longitude}' : null, + ), + _PropertyItem( + label: 'Location', + value: [exif.city, exif.state, exif.country].where((e) => e != null && e.isNotEmpty).join(', '), + ), + ]); + } + + String _getAssetTypeTitle(BaseAsset asset) { + if (asset is LocalAsset) return 'Local Asset'; + if (asset is RemoteAsset) return 'Remote Asset'; + return 'Base Asset'; + } +} + +class _LocalAssetsSection extends ConsumerWidget { + final BaseAsset asset; + + const _LocalAssetsSection({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assetService = ref.watch(assetServiceProvider); + + return FutureBuilder>( + future: assetService.getLocalAssetsByChecksum(asset.checksum!), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _PropertySectionCard( + title: 'Local Assets', + properties: [_PropertyItem(label: 'Status', value: 'Loading...')], + ); + } + + if (snapshot.hasError) { + return _PropertySectionCard( + title: 'Local Assets', + properties: [_PropertyItem(label: 'Error', value: snapshot.error.toString())], + ); + } + + final localAssets = snapshot.data?.cast() ?? []; + if (asset is LocalAsset) { + localAssets.removeWhere((a) => a.id == (asset as LocalAsset).id); + + if (localAssets.isEmpty) { + return const SizedBox.shrink(); + } + } + + if (localAssets.isEmpty) { + return const _PropertySectionCard( + title: 'Local Assets', + properties: [_PropertyItem(label: 'Status', value: 'No local assets found with this checksum')], + ); + } + + return Column( + children: [ + if (localAssets.length > 1) + _PropertySectionCard( + title: 'Local Assets Summary', + properties: [_PropertyItem(label: 'Total Count', value: localAssets.length.toString())], + ), + ...localAssets.map((localAsset) { + return Padding( + padding: const EdgeInsets.only(top: 16), + child: _AssetPropertiesSection(asset: localAsset), + ); + }), + ], + ); + }, + ); + } +} + +class _RemoteAssetSection extends ConsumerWidget { + final BaseAsset asset; + + const _RemoteAssetSection({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assetService = ref.watch(assetServiceProvider); + + if (asset is RemoteAsset) { + return const SizedBox.shrink(); + } + + return FutureBuilder( + future: assetService.getRemoteAssetByChecksum(asset.checksum!), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _PropertySectionCard( + title: 'Remote Assets', + properties: [_PropertyItem(label: 'Status', value: 'Loading...')], + ); + } + + if (snapshot.hasError) { + return _PropertySectionCard( + title: 'Remote Assets', + properties: [_PropertyItem(label: 'Error', value: snapshot.error.toString())], + ); + } + + final remoteAsset = snapshot.data; + + if (remoteAsset == null) { + return const _PropertySectionCard( + title: 'Remote Assets', + properties: [_PropertyItem(label: 'Status', value: 'No remote asset found with this checksum')], + ); + } + + return _AssetPropertiesSection(asset: remoteAsset); + }, + ); + } +} + +class _PropertySectionCard extends StatelessWidget { + final String title; + final List<_PropertyItem> properties; + + const _PropertySectionCard({required this.title, required this.properties}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ...properties, + ], + ), + ), + ); + } +} + +class _PropertyItem extends StatelessWidget { + final String label; + final String? value; + + const _PropertyItem({required this.label, this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text('$label:', style: const TextStyle(fontWeight: FontWeight.w500)), + ), + Expanded( + child: Text(value ?? 'N/A', style: TextStyle(color: Theme.of(context).colorScheme.secondary)), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart new file mode 100644 index 0000000000..170f827fdb --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.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/providers/infrastructure/action.provider.dart'; + +class AdvancedInfoActionButton extends ConsumerWidget { + final ActionSource source; + + const AdvancedInfoActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + ref.read(actionProvider.notifier).troubleshoot(source, context); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + maxWidth: 115.0, + iconData: Icons.help_outline_rounded, + label: "troubleshoot".t(context: context), + onPressed: () => _onTap(context, ref), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index ae55fb671b..7431290ad8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; @@ -14,6 +15,7 @@ import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -41,6 +43,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { final isInLockedView = ref.watch(inLockedViewProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider); final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); final buttonContext = ActionButtonContext( asset: asset, @@ -49,6 +52,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { isTrashEnabled: isTrashEnable, isInLockedView: isInLockedView, currentAlbum: currentAlbum, + advancedTroubleshooting: advancedTroubleshooting, source: ActionSource.viewer, ); @@ -122,6 +126,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget { return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); } + Future _editDateTime(BuildContext context, WidgetRef ref) async { + await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); + } + @override Widget build(BuildContext context, WidgetRef ref) { final asset = ref.watch(currentAssetNotifier); @@ -132,10 +140,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final cameraTitle = _getCameraInfoTitle(exifInfo); - Future editDateTime() async { - await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); - } - return SliverList.list( children: [ // Asset Date and Time @@ -143,7 +147,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { title: _getDateTime(context, asset), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null, - onTap: asset.hasRemote ? () async => await editDateTime() : null, + onTap: asset.hasRemote ? () async => await _editDateTime(context, ref) : null, ), if (exifInfo != null) _SheetAssetDescription(exif: exifInfo), const SheetPeopleDetails(), 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 45c602935d..0ac0bab81d 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 @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; 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 f1f092d2e2..a9496423f6 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 @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.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/setting.model.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; @@ -21,6 +23,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_ import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -51,6 +54,7 @@ class _GeneralBottomSheetState extends ConsumerState { Widget build(BuildContext context) { final multiselect = ref.watch(multiSelectProvider); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); Future addAssetsToAlbum(RemoteAlbum album) async { final selectedAssets = multiselect.selectedAssets; @@ -88,6 +92,9 @@ class _GeneralBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ + if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[ + const AdvancedInfoActionButton(source: ActionSource.timeline), + ], const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 3da653444c..e5a26272b0 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; -import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; @@ -125,94 +124,49 @@ class AppLifeCycleNotifier extends StateNotifier { } } + Future _safeRun(Future action, String debugName) async { + if (!_shouldContinueOperation()) { + return; + } + + try { + await action; + } catch (e, stackTrace) { + _log.warning("Error during $debugName operation", e, stackTrace); + } + } + Future _handleBetaTimelineResume() async { _ref.read(backupProvider.notifier).cancelBackup(); - final lockManager = _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)); // Give isolates time to complete any ongoing database transactions await Future.delayed(const Duration(milliseconds: 500)); - lockManager.requestHolderToClose(); - - // Add timeout to prevent deadlock on lock acquisition - try { - await lockManager.acquireLock().timeout( - const Duration(seconds: 10), - onTimeout: () { - _log.warning("Lock acquisition timed out, proceeding without lock"); - throw TimeoutException("Lock acquisition timed out", const Duration(seconds: 10)); - }, - ); - } catch (e) { - _log.warning("Failed to acquire lock: $e"); - return; - } - final backgroundManager = _ref.read(backgroundSyncProvider); final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); try { // Run operations sequentially with state checks and error handling for each - if (_shouldContinueOperation()) { - try { - await backgroundManager.syncLocal(); - } catch (e, stackTrace) { - _log.warning("Failed syncLocal: $e", e, stackTrace); - } - } - - // Check if app is still active before hashing - if (_shouldContinueOperation()) { - try { - await backgroundManager.hashAssets(); - } catch (e, stackTrace) { - _log.warning("Failed hashAssets: $e", e, stackTrace); - } - } - - // Check if app is still active before remote sync - if (_shouldContinueOperation()) { - try { - await backgroundManager.syncRemote(); - } catch (e, stackTrace) { - _log.warning("Failed syncRemote: $e", e, stackTrace); - } - - if (isAlbumLinkedSyncEnable && _shouldContinueOperation()) { - try { - await backgroundManager.syncLinkedAlbum(); - } catch (e, stackTrace) { - _log.warning("Failed syncLinkedAlbum: $e", e, stackTrace); - } - } + await _safeRun(backgroundManager.syncLocal(), "syncLocal"); + await _safeRun(backgroundManager.syncRemote(), "syncRemote"); + await _safeRun(backgroundManager.hashAssets(), "hashAssets"); + if (isAlbumLinkedSyncEnable) { + await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum"); } // Handle backup resume only if still active - if (_shouldContinueOperation()) { - final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); - - if (isEnableBackup) { - final currentUser = _ref.read(currentUserProvider); - if (currentUser != null) { - try { - await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); - _log.fine("Completed backup resume"); - } catch (e, stackTrace) { - _log.warning("Failed backup resume: $e", e, stackTrace); - } - } + if (isEnableBackup) { + final currentUser = _ref.read(currentUserProvider); + if (currentUser != null) { + await _safeRun( + _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id), + "handleBackupResume", + ); } } } catch (e, stackTrace) { _log.severe("Error during background sync", e, stackTrace); - } finally { - // Ensure lock is released even if operations fail - try { - lockManager.releaseLock(); - _log.fine("Lock released after background sync operations"); - } catch (lockError) { - _log.warning("Failed to release lock after error: $lockError"); - } } } @@ -263,28 +217,6 @@ class AppLifeCycleNotifier extends StateNotifier { if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { _ref.read(backupProvider.notifier).cancelBackup(); } - } else { - final backgroundManager = _ref.read(backgroundSyncProvider); - - // Cancel operations with extended timeout to allow database transactions to complete - try { - await Future.wait([ - backgroundManager.cancel().timeout(const Duration(seconds: 10)), - backgroundManager.cancelLocal().timeout(const Duration(seconds: 10)), - ]).timeout(const Duration(seconds: 15)); - - // Give additional time for isolates to clean up database connections - await Future.delayed(const Duration(milliseconds: 1000)); - } catch (e) { - _log.warning("Timeout during background cancellation: $e"); - } - - // Always release the lock, even if cancellation failed - try { - _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock(); - } catch (e) { - _log.warning("Failed to release lock on pause: $e"); - } } _ref.read(websocketProvider.notifier).disconnect(); @@ -312,7 +244,6 @@ class AppLifeCycleNotifier extends StateNotifier { } catch (_) {} if (Store.isBetaTimelineEnabled) { - _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock(); return; } diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index 1981c45fb1..e6e83b64df 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; -import 'package:immich_mobile/domain/utils/isolate_lock_manager.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart'; final backgroundSyncProvider = Provider((ref) { @@ -19,7 +18,3 @@ final backgroundSyncProvider = Provider((ref) { ref.onDispose(manager.cancel); return manager; }); - -final isolateLockManagerProvider = Provider.family((ref, name) { - return IsolateLockManager(portName: name); -}); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 6035e53e5d..76cb383465 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -18,7 +17,6 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; -import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; @@ -36,8 +34,6 @@ import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; -final driftBackgroundUploadFgService = Provider((ref) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); - final backupProvider = StateNotifierProvider((ref) { return BackupNotifier( ref.watch(backupServiceProvider), diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 418410de0c..21bee38004 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -6,11 +6,11 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; - import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:logging/logging.dart'; @@ -380,5 +380,5 @@ final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family< ref, assetId, ) { - return ref.read(backupRepositoryProvider).getSourceAlbums(assetId); + return ref.read(localAssetRepository).getSourceAlbums(assetId, backupSelection: BackupSelection.selected); }); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 65b4327b7a..03e2dfc6d5 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,3 +1,4 @@ +import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -6,6 +7,7 @@ import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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'; import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/timeline.service.dart'; @@ -115,6 +117,16 @@ class ActionNotifier extends Notifier { }; } + Future troubleshoot(ActionSource source, BuildContext context) async { + final assets = _getAssets(source); + if (assets.length > 1) { + return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets'); + } + context.pushRoute(AssetTroubleshootRoute(asset: assets.first)); + + return ActionResult(count: assets.length, success: true); + } + Future shareLink(ActionSource source, BuildContext context) async { final ids = _getRemoteIdsForSource(source); try { diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 6469624c09..05901a4fec 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -1,7 +1,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; +import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/thumbnail_api.g.dart'; +final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); + final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); final thumbnailApi = ThumbnailApi(); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b289cc3225..cdf384fcf8 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -76,7 +76,7 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart'; import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; -import 'package:immich_mobile/pages/settings/beta_sync_settings.page.dart'; +import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; @@ -86,6 +86,7 @@ import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; @@ -332,7 +333,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: ChangeExperienceRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: BetaSyncSettingsRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: SyncStatusRoute.page, guards: [_duplicateGuard]), AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), @@ -343,6 +344,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftFilterImageRoute.page), AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 84f2685ab5..981828acf1 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -403,6 +403,43 @@ class ArchiveRoute extends PageRouteInfo { ); } +/// generated route for +/// [AssetTroubleshootPage] +class AssetTroubleshootRoute extends PageRouteInfo { + AssetTroubleshootRoute({ + Key? key, + required BaseAsset asset, + List? children, + }) : super( + AssetTroubleshootRoute.name, + args: AssetTroubleshootRouteArgs(key: key, asset: asset), + initialChildren: children, + ); + + static const String name = 'AssetTroubleshootRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AssetTroubleshootPage(key: args.key, asset: args.asset); + }, + ); +} + +class AssetTroubleshootRouteArgs { + const AssetTroubleshootRouteArgs({this.key, required this.asset}); + + final Key? key; + + final BaseAsset asset; + + @override + String toString() { + return 'AssetTroubleshootRouteArgs{key: $key, asset: $asset}'; + } +} + /// generated route for /// [AssetViewerPage] class AssetViewerRoute extends PageRouteInfo { @@ -509,22 +546,6 @@ class BackupOptionsRoute extends PageRouteInfo { ); } -/// generated route for -/// [BetaSyncSettingsPage] -class BetaSyncSettingsRoute extends PageRouteInfo { - const BetaSyncSettingsRoute({List? children}) - : super(BetaSyncSettingsRoute.name, initialChildren: children); - - static const String name = 'BetaSyncSettingsRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const BetaSyncSettingsPage(); - }, - ); -} - /// generated route for /// [ChangeExperiencePage] class ChangeExperienceRoute extends PageRouteInfo { @@ -2629,6 +2650,22 @@ class SplashScreenRoute extends PageRouteInfo { ); } +/// generated route for +/// [SyncStatusPage] +class SyncStatusRoute extends PageRouteInfo { + const SyncStatusRoute({List? children}) + : super(SyncStatusRoute.name, initialChildren: children); + + static const String name = 'SyncStatusRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SyncStatusPage(); + }, + ); +} + /// generated route for /// [TabControllerPage] class TabControllerRoute extends PageRouteInfo { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index d98b14408f..d53cd85b95 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -46,7 +46,7 @@ enum AppSettingsEnum { syncAlbums(StoreKey.syncAlbums, null, false), autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), photoManagerCustomFilter(StoreKey.photoManagerCustomFilter, null, true), - betaTimeline(StoreKey.betaTimeline, null, false), + betaTimeline(StoreKey.betaTimeline, null, true), enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 10facea9a2..4dfc0398bd 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:immich_mobile/constants/enums.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/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; @@ -15,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; class ActionButtonContext { final BaseAsset asset; @@ -24,6 +25,7 @@ class ActionButtonContext { final bool isTrashEnabled; final bool isInLockedView; final RemoteAlbum? currentAlbum; + final bool advancedTroubleshooting; final ActionSource source; const ActionButtonContext({ @@ -33,11 +35,13 @@ class ActionButtonContext { required this.isTrashEnabled, required this.isInLockedView, required this.currentAlbum, + required this.advancedTroubleshooting, required this.source, }); } enum ActionButtonType { + advancedInfo, share, shareLink, archive, @@ -55,6 +59,7 @@ enum ActionButtonType { bool shouldShow(ActionButtonContext context) { return switch (this) { + ActionButtonType.advancedInfo => context.advancedTroubleshooting, ActionButtonType.share => true, ActionButtonType.shareLink => !context.isInLockedView && // @@ -115,6 +120,7 @@ enum ActionButtonType { Widget buildButton(ActionButtonContext context) { return switch (this) { + ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source), ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), ActionButtonType.archive => ArchiveActionButton(source: context.source), @@ -138,6 +144,7 @@ enum ActionButtonType { class ActionButtonBuilder { static const List _actionTypes = [ + ActionButtonType.advancedInfo, ActionButtonType.share, ActionButtonType.shareLink, ActionButtonType.likeActivity, diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index e7abc66040..c7d7cb8192 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -90,7 +90,7 @@ abstract final class Bootstrap { } static Future initDomain(Isar db, Drift drift, DriftLogger logDb, {bool shouldBufferLogs = true}) async { - final isBeta = await IsarStoreRepository(db).tryGet(StoreKey.betaTimeline) ?? false; + final isBeta = await IsarStoreRepository(db).tryGet(StoreKey.betaTimeline) ?? true; final IStoreRepository storeRepo = isBeta ? DriftStoreRepository(drift) : IsarStoreRepository(db); await StoreService.init(storeRepository: storeRepo); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 0a786fed0b..e5182a5999 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -5,7 +5,6 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -23,22 +22,16 @@ 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/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/background_sync.provider.dart'; -import 'package:immich_mobile/providers/backup/backup.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 14; +const int targetVersion = 15; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; final int version = Store.get(StoreKey.version, targetVersion); - if (version < 9) { await Store.put(StoreKey.version, targetVersion); final value = await db.storeValues.get(StoreKey.currentUser.id); @@ -68,6 +61,26 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await Store.populateCache(); } + // Handle migration only for this version + // TODO: remove when old timeline is removed + final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration); + if (version == 15 && needBetaMigration == null) { + // Check both databases directly instead of relying on cache + + final isBeta = Store.tryGet(StoreKey.betaTimeline); + final isNewInstallation = await _isNewInstallation(db, drift); + + // For new installations, no migration needed + // For existing installations, only migrate if beta timeline is not enabled (null or false) + if (isNewInstallation || isBeta == true) { + await Store.put(StoreKey.needBetaMigration, false); + await Store.put(StoreKey.betaTimeline, true); + } else { + await resetDriftDatabase(drift); + await Store.put(StoreKey.needBetaMigration, true); + } + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -80,6 +93,35 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { } } +Future _isNewInstallation(Isar db, Drift drift) async { + try { + final isarUserCount = await db.users.count(); + if (isarUserCount > 0) { + return false; + } + + final isarAssetCount = await db.assets.count(); + if (isarAssetCount > 0) { + return false; + } + + final driftStoreCount = await drift.storeEntity.select().get().then((list) => list.length); + if (driftStoreCount > 0) { + return false; + } + + final driftAssetCount = await drift.localAssetEntity.select().get().then((list) => list.length); + if (driftAssetCount > 0) { + return false; + } + + return true; + } catch (error) { + debugPrint("[MIGRATION] Error checking if new installation: $error"); + return false; + } +} + Future _migrateTo(Isar db, int version) async { await Store.delete(StoreKey.assetETag); await db.writeTxn(() async { @@ -266,21 +308,26 @@ class _DeviceAsset { const _DeviceAsset({required this.assetId, this.hash, this.dateTime}); } -Future> runNewSync(WidgetRef ref, {bool full = false}) { - ref.read(backupProvider.notifier).cancelBackup(); +Future resetDriftDatabase(Drift drift) async { + // https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94 + final database = drift.attachedDatabase; + await database.exclusively(() async { + // https://stackoverflow.com/a/65743498/25690041 + await database.customStatement('PRAGMA writable_schema = 1;'); + await database.customStatement('DELETE FROM sqlite_master;'); + await database.customStatement('VACUUM;'); + await database.customStatement('PRAGMA writable_schema = 0;'); + await database.customStatement('PRAGMA integrity_check'); - final backgroundManager = ref.read(backgroundSyncProvider); - final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + await database.customStatement('PRAGMA user_version = 0'); + await database.beforeOpen( + // ignore: invalid_use_of_internal_member + database.resolvedEngine.executor, + OpeningDetails(null, database.schemaVersion), + ); + await database.customStatement('PRAGMA user_version = ${database.schemaVersion}'); - return Future.wait([ - backgroundManager.syncLocal(full: full).then((_) { - Logger("runNewSync").fine("Hashing assets after syncLocal"); - return backgroundManager.hashAssets(); - }), - backgroundManager.syncRemote().then((_) { - if (isAlbumLinkedSyncEnable) { - return backgroundManager.syncLinkedAlbum(); - } - }), - ]); + // Refresh all stream queries + database.notifyUpdates({for (final table in database.allTables) TableUpdate.onTable(table)}); + }); } diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index a9c7a467c2..00366ca580 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -90,11 +90,11 @@ class AppBarProfileInfoBox extends HookConsumerWidget { minLeadingWidth: 50, leading: GestureDetector( onTap: pickUserProfileImage, - onDoubleTap: toggleReadonlyMode, + onLongPress: toggleReadonlyMode, child: Stack( clipBehavior: Clip.none, children: [ - buildUserProfileImage(), + AbsorbPointer(child: buildUserProfileImage()), if (!isReadonlyModeEnabled) Positioned( bottom: -5, diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index ee111851ad..378a31f33e 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -157,7 +157,7 @@ class _ProfileIndicator extends ConsumerWidget { return InkWell( onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()), - onDoubleTap: () => toggleReadonlyMode(), + onLongPress: () => toggleReadonlyMode(), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( label: Container( @@ -173,7 +173,7 @@ class _ProfileIndicator extends ConsumerWidget { ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( label: "logged_in_as".tr(namedArgs: {"user": user.name}), - child: UserCircleAvatar(radius: 17, size: 31, user: user), + child: AbsorbPointer(child: UserCircleAvatar(radius: 17, size: 31, user: user)), ), ), ); diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index ab78536a92..7b5d31544a 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -10,9 +10,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; 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/extensions/build_context_extensions.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'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; @@ -161,6 +163,18 @@ class LoginForm extends HookConsumerWidget { serverEndpointController.text = 'http://10.1.15.216:2283/api'; } + Future handleSyncFlow() async { + final backgroundManager = ref.read(backgroundSyncProvider); + + await backgroundManager.syncLocal(full: true); + await backgroundManager.syncRemote(); + await backgroundManager.hashAssets(); + + if (Store.get(StoreKey.syncAlbums, false)) { + await backgroundManager.syncLinkedAlbum(); + } + } + login() async { TextInput.finishAutofillContext(); @@ -178,7 +192,7 @@ class LoginForm extends HookConsumerWidget { final isBeta = Store.isBetaTimelineEnabled; if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - + handleSyncFlow(); context.replaceRoute(const TabShellRoute()); return; } @@ -276,6 +290,7 @@ class LoginForm extends HookConsumerWidget { } if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); + handleSyncFlow(); context.replaceRoute(const TabShellRoute()); return; } diff --git a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart deleted file mode 100644 index e5c65a9c67..0000000000 --- a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart +++ /dev/null @@ -1,376 +0,0 @@ -import 'dart:io'; - -import 'package:drift/drift.dart' as drift_db; -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/extensions/translate_extensions.dart'; -import 'package:immich_mobile/providers/background_sync.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; -import 'package:immich_mobile/providers/sync_status.provider.dart'; -import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; - -class BetaSyncSettings extends HookConsumerWidget { - const BetaSyncSettings({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final assetService = ref.watch(assetServiceProvider); - final localAlbumService = ref.watch(localAlbumServiceProvider); - final remoteAlbumService = ref.watch(remoteAlbumServiceProvider); - final memoryService = ref.watch(driftMemoryServiceProvider); - - Future> loadCounts() async { - final assetCounts = assetService.getAssetCounts(); - final localAlbumCounts = localAlbumService.getCount(); - final remoteAlbumCounts = remoteAlbumService.getCount(); - final memoryCount = memoryService.getCount(); - final getLocalHashedCount = assetService.getLocalHashedCount(); - - return await Future.wait([assetCounts, localAlbumCounts, remoteAlbumCounts, memoryCount, getLocalHashedCount]); - } - - Future resetDatabase() async { - // https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94 - final drift = ref.read(driftProvider); - final database = drift.attachedDatabase; - await database.exclusively(() async { - // https://stackoverflow.com/a/65743498/25690041 - await database.customStatement('PRAGMA writable_schema = 1;'); - await database.customStatement('DELETE FROM sqlite_master;'); - await database.customStatement('VACUUM;'); - await database.customStatement('PRAGMA writable_schema = 0;'); - await database.customStatement('PRAGMA integrity_check'); - - await database.customStatement('PRAGMA user_version = 0'); - await database.beforeOpen( - // ignore: invalid_use_of_internal_member - database.resolvedEngine.executor, - drift_db.OpeningDetails(null, database.schemaVersion), - ); - await database.customStatement('PRAGMA user_version = ${database.schemaVersion}'); - - // Refresh all stream queries - database.notifyUpdates({for (final table in database.allTables) drift_db.TableUpdate.onTable(table)}); - }); - } - - Future exportDatabase() async { - try { - // WAL Checkpoint to ensure all changes are written to the database - await ref.read(driftProvider).customStatement("pragma wal_checkpoint(truncate)"); - final documentsDir = await getApplicationDocumentsDirectory(); - final dbFile = File(path.join(documentsDir.path, 'immich.sqlite')); - - if (!await dbFile.exists()) { - if (context.mounted) { - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("Database file not found".t(context: context))), - ); - } - return; - } - - final timestamp = DateTime.now().millisecondsSinceEpoch; - final exportFile = File(path.join(documentsDir.path, 'immich_export_$timestamp.sqlite')); - - await dbFile.copy(exportFile.path); - - await Share.shareXFiles([XFile(exportFile.path)], text: 'Immich Database Export'); - - Future.delayed(const Duration(seconds: 30), () async { - if (await exportFile.exists()) { - await exportFile.delete(); - } - }); - - if (context.mounted) { - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("Database exported successfully".t(context: context))), - ); - } - } catch (e) { - if (context.mounted) { - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("Failed to export database: $e".t(context: context))), - ); - } - } - } - - Future clearFileCache() async { - await ref.read(storageRepositoryProvider).clearCache(); - } - - Future resetSqliteDb(BuildContext context, Future Function() resetDatabase) { - return showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text("reset_sqlite".t(context: context)), - content: Text("reset_sqlite_confirmation".t(context: context)), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text("cancel".t(context: context)), - ), - TextButton( - onPressed: () async { - await resetDatabase(); - context.pop(); - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("reset_sqlite_success".t(context: context))), - ); - }, - child: Text( - "confirm".t(context: context), - style: TextStyle(color: context.colorScheme.error), - ), - ), - ], - ); - }, - ); - } - - return FutureBuilder>( - future: loadCounts(), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const CircularProgressIndicator(); - } - - if (snapshot.hasError) { - return ListView( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Center( - child: Text( - "Error occur, reset the local database by tapping the button below", - style: context.textTheme.bodyLarge, - ), - ), - ), - - ListTile( - title: Text( - "reset_sqlite".t(context: context), - style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), - ), - leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), - onTap: () async { - await resetSqliteDb(context, resetDatabase); - }, - ), - ], - ); - } - - final assetCounts = snapshot.data![0]! as (int, int); - final localAssetCount = assetCounts.$1; - final remoteAssetCount = assetCounts.$2; - - final localAlbumCount = snapshot.data![1]! as int; - final remoteAlbumCount = snapshot.data![2]! as int; - final memoryCount = snapshot.data![3]! as int; - final localHashedCount = snapshot.data![4]! as int; - - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 32), - child: ListView( - children: [ - _SectionHeaderText(text: "assets".t(context: context)), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Flex( - direction: Axis.horizontal, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8.0, - children: [ - Expanded( - child: EntitiyCountTile( - label: "local".t(context: context), - count: localAssetCount, - icon: Icons.smartphone, - ), - ), - Expanded( - child: EntitiyCountTile( - label: "remote".t(context: context), - count: remoteAssetCount, - icon: Icons.cloud, - ), - ), - ], - ), - ), - _SectionHeaderText(text: "albums".t(context: context)), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Flex( - direction: Axis.horizontal, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8.0, - children: [ - Expanded( - child: EntitiyCountTile( - label: "local".t(context: context), - count: localAlbumCount, - icon: Icons.smartphone, - ), - ), - Expanded( - child: EntitiyCountTile( - label: "remote".t(context: context), - count: remoteAlbumCount, - icon: Icons.cloud, - ), - ), - ], - ), - ), - _SectionHeaderText(text: "other".t(context: context)), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Flex( - direction: Axis.horizontal, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8.0, - children: [ - Expanded( - child: EntitiyCountTile( - label: "memories".t(context: context), - count: memoryCount, - icon: Icons.calendar_today, - ), - ), - Expanded( - child: EntitiyCountTile( - label: "hashed_assets".t(context: context), - count: localHashedCount, - icon: Icons.tag, - ), - ), - ], - ), - ), - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 24), - _SectionHeaderText(text: "jobs".t(context: context)), - ListTile( - title: Text( - "sync_local".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("tap_to_run_job".t(context: context)), - leading: const Icon(Icons.sync), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), - onTap: () { - ref.read(backgroundSyncProvider).syncLocal(full: true); - }, - ), - ListTile( - title: Text( - "sync_remote".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("tap_to_run_job".t(context: context)), - leading: const Icon(Icons.cloud_sync), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), - onTap: () { - ref.read(backgroundSyncProvider).syncRemote(); - }, - ), - ListTile( - title: Text( - "hash_asset".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - leading: const Icon(Icons.tag), - subtitle: Text("tap_to_run_job".t(context: context)), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), - onTap: () { - ref.read(backgroundSyncProvider).hashAssets(); - }, - ), - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 24), - _SectionHeaderText(text: "actions".t(context: context)), - ListTile( - title: Text( - "clear_file_cache".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - leading: const Icon(Icons.playlist_remove_rounded), - onTap: clearFileCache, - ), - ListTile( - title: Text( - "export_database".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("export_database_description".t(context: context)), - leading: const Icon(Icons.download), - onTap: exportDatabase, - ), - ListTile( - title: Text( - "reset_sqlite".t(context: context), - style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), - ), - leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), - onTap: () async { - await resetSqliteDb(context, resetDatabase); - }, - ), - ], - ), - ); - }, - ); - } -} - -class _SyncStatusIcon extends StatelessWidget { - final SyncStatus status; - - const _SyncStatusIcon({required this.status}); - - @override - Widget build(BuildContext context) { - return switch (status) { - SyncStatus.idle => const Icon(Icons.pause_circle_outline_rounded), - SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)), - SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green), - SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error), - }; - } -} - -class _SectionHeaderText extends StatelessWidget { - final String text; - - const _SectionHeaderText({required this.text}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text( - text.toUpperCase(), - style: context.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w500, - color: context.colorScheme.onSurface.withAlpha(200), - ), - ), - ); - } -} 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 new file mode 100644 index 0000000000..15c015736b --- /dev/null +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -0,0 +1,355 @@ +import 'dart:io'; + +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/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/providers/sync_status.provider.dart'; +import 'package:immich_mobile/utils/migration.dart'; +import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class SyncStatusAndActions extends HookConsumerWidget { + const SyncStatusAndActions({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future exportDatabase() async { + try { + // WAL Checkpoint to ensure all changes are written to the database + await ref.read(driftProvider).customStatement("pragma wal_checkpoint(truncate)"); + final documentsDir = await getApplicationDocumentsDirectory(); + final dbFile = File(path.join(documentsDir.path, 'immich.sqlite')); + + if (!await dbFile.exists()) { + if (context.mounted) { + context.scaffoldMessenger.showSnackBar( + SnackBar(content: Text("Database file not found".t(context: context))), + ); + } + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final exportFile = File(path.join(documentsDir.path, 'immich_export_$timestamp.sqlite')); + + await dbFile.copy(exportFile.path); + + await Share.shareXFiles([XFile(exportFile.path)], text: 'Immich Database Export'); + + Future.delayed(const Duration(seconds: 30), () async { + if (await exportFile.exists()) { + await exportFile.delete(); + } + }); + + if (context.mounted) { + context.scaffoldMessenger.showSnackBar( + SnackBar(content: Text("Database exported successfully".t(context: context))), + ); + } + } catch (e) { + if (context.mounted) { + context.scaffoldMessenger.showSnackBar( + SnackBar(content: Text("Failed to export database: $e".t(context: context))), + ); + } + } + } + + Future clearFileCache() async { + await ref.read(storageRepositoryProvider).clearCache(); + } + + Future resetSqliteDb(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("reset_sqlite".t(context: context)), + content: Text("reset_sqlite_confirmation".t(context: context)), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text("cancel".t(context: context)), + ), + TextButton( + onPressed: () async { + await resetDriftDatabase(ref.read(driftProvider)); + context.pop(); + context.scaffoldMessenger.showSnackBar( + SnackBar(content: Text("reset_sqlite_success".t(context: context))), + ); + }, + child: Text( + "confirm".t(context: context), + style: TextStyle(color: context.colorScheme.error), + ), + ), + ], + ); + }, + ); + } + + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 32), + child: ListView( + children: [ + const _SyncStatsCounts(), + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 24), + _SectionHeaderText(text: "jobs".t(context: context)), + ListTile( + title: Text( + "sync_local".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text("tap_to_run_job".t(context: context)), + leading: const Icon(Icons.sync), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), + onTap: () { + ref.read(backgroundSyncProvider).syncLocal(full: true); + }, + ), + ListTile( + title: Text( + "sync_remote".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text("tap_to_run_job".t(context: context)), + leading: const Icon(Icons.cloud_sync), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), + onTap: () { + ref.read(backgroundSyncProvider).syncRemote(); + }, + ), + ListTile( + title: Text( + "hash_asset".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + leading: const Icon(Icons.tag), + subtitle: Text("tap_to_run_job".t(context: context)), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), + onTap: () { + ref.read(backgroundSyncProvider).hashAssets(); + }, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 24), + _SectionHeaderText(text: "actions".t(context: context)), + ListTile( + title: Text( + "clear_file_cache".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + leading: const Icon(Icons.playlist_remove_rounded), + onTap: clearFileCache, + ), + ListTile( + title: Text( + "export_database".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text("export_database_description".t(context: context)), + leading: const Icon(Icons.download), + onTap: exportDatabase, + ), + ListTile( + title: Text( + "reset_sqlite".t(context: context), + style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), + ), + leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), + onTap: () async { + await resetSqliteDb(context); + }, + ), + ], + ), + ); + } +} + +class _SyncStatusIcon extends StatelessWidget { + final SyncStatus status; + + const _SyncStatusIcon({required this.status}); + + @override + Widget build(BuildContext context) { + return switch (status) { + SyncStatus.idle => const Icon(Icons.pause_circle_outline_rounded), + SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)), + SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green), + SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error), + }; + } +} + +class _SectionHeaderText extends StatelessWidget { + final String text; + + const _SectionHeaderText({required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + text.toUpperCase(), + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withAlpha(200), + ), + ), + ); + } +} + +class _SyncStatsCounts extends ConsumerWidget { + const _SyncStatsCounts(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assetService = ref.watch(assetServiceProvider); + final localAlbumService = ref.watch(localAlbumServiceProvider); + final remoteAlbumService = ref.watch(remoteAlbumServiceProvider); + final memoryService = ref.watch(driftMemoryServiceProvider); + + Future> loadCounts() async { + final assetCounts = assetService.getAssetCounts(); + final localAlbumCounts = localAlbumService.getCount(); + final remoteAlbumCounts = remoteAlbumService.getCount(); + final memoryCount = memoryService.getCount(); + final getLocalHashedCount = assetService.getLocalHashedCount(); + + return await Future.wait([assetCounts, localAlbumCounts, remoteAlbumCounts, memoryCount, getLocalHashedCount]); + } + + return FutureBuilder( + future: loadCounts(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: SizedBox(height: 48, width: 48, child: CircularProgressIndicator())); + } + + if (snapshot.hasError) { + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + "Error occur, reset the local database by tapping the button below", + style: context.textTheme.bodyLarge, + ), + ), + ), + ], + ); + } + + final assetCounts = snapshot.data![0]! as (int, int); + final localAssetCount = assetCounts.$1; + final remoteAssetCount = assetCounts.$2; + + final localAlbumCount = snapshot.data![1]! as int; + final remoteAlbumCount = snapshot.data![2]! as int; + final memoryCount = snapshot.data![3]! as int; + final localHashedCount = snapshot.data![4]! as int; + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeaderText(text: "assets".t(context: context)), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8.0, + children: [ + Expanded( + child: EntitiyCountTile( + label: "local".t(context: context), + count: localAssetCount, + icon: Icons.smartphone, + ), + ), + Expanded( + child: EntitiyCountTile( + label: "remote".t(context: context), + count: remoteAssetCount, + icon: Icons.cloud, + ), + ), + ], + ), + ), + _SectionHeaderText(text: "albums".t(context: context)), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8.0, + children: [ + Expanded( + child: EntitiyCountTile( + label: "local".t(context: context), + count: localAlbumCount, + icon: Icons.smartphone, + ), + ), + Expanded( + child: EntitiyCountTile( + label: "remote".t(context: context), + count: remoteAlbumCount, + icon: Icons.cloud, + ), + ), + ], + ), + ), + _SectionHeaderText(text: "other".t(context: context)), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8.0, + children: [ + Expanded( + child: EntitiyCountTile( + label: "memories".t(context: context), + count: memoryCount, + icon: Icons.calendar_today, + ), + ), + Expanded( + child: EntitiyCountTile( + label: "hashed_assets".t(context: context), + count: localHashedCount, + icon: Icons.tag, + ), + ), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index bca1b5219a..ad9f062599 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -107,7 +107,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | -*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset +*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace the asset with new file, without changing its id *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | *AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | @@ -128,6 +128,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | +*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace the asset with new file, without changing its id *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index e16ac2f535..063f9ea43b 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -729,9 +729,9 @@ class AssetsApi { return null; } - /// replaceAsset + /// Replace the asset with new file, without changing its id /// - /// Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. /// /// Note: This method returns the HTTP [Response]. /// @@ -823,9 +823,9 @@ class AssetsApi { ); } - /// replaceAsset + /// Replace the asset with new file, without changing its id /// - /// Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index cdcd27750d..9246998ca2 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -127,4 +127,138 @@ class DeprecatedApi { } return null; } + + /// Replace the asset with new file, without changing its id + /// + /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MultipartFile] assetData (required): + /// + /// * [String] deviceAssetId (required): + /// + /// * [String] deviceId (required): + /// + /// * [DateTime] fileCreatedAt (required): + /// + /// * [DateTime] fileModifiedAt (required): + /// + /// * [String] key: + /// + /// * [String] slug: + /// + /// * [String] duration: + /// + /// * [String] filename: + Future replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/original' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = ['multipart/form-data']; + + bool hasFields = false; + final mp = MultipartRequest('PUT', Uri.parse(apiPath)); + if (assetData != null) { + hasFields = true; + mp.fields[r'assetData'] = assetData.field; + mp.files.add(assetData); + } + if (deviceAssetId != null) { + hasFields = true; + mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId); + } + if (deviceId != null) { + hasFields = true; + mp.fields[r'deviceId'] = parameterToString(deviceId); + } + if (duration != null) { + hasFields = true; + mp.fields[r'duration'] = parameterToString(duration); + } + if (fileCreatedAt != null) { + hasFields = true; + mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt); + } + if (fileModifiedAt != null) { + hasFields = true; + mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt); + } + if (filename != null) { + hasFields = true; + mp.fields[r'filename'] = parameterToString(filename); + } + if (hasFields) { + postBody = mp; + } + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Replace the asset with new file, without changing its id + /// + /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MultipartFile] assetData (required): + /// + /// * [String] deviceAssetId (required): + /// + /// * [String] deviceId (required): + /// + /// * [DateTime] fileCreatedAt (required): + /// + /// * [DateTime] fileModifiedAt (required): + /// + /// * [String] key: + /// + /// * [String] slug: + /// + /// * [String] duration: + /// + /// * [String] filename: + Future replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async { + final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto; + + } + return null; + } } diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 2d142e3d67..70ac076c9d 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -53,12 +53,15 @@ class TimelineApi { /// * [AssetVisibility] visibility: /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// + /// * [bool] withCoordinates: + /// Include location data in the response + /// /// * [bool] withPartners: /// Include assets shared by partners /// /// * [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? withPartners, bool? withStacked, }) async { + 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 { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -100,6 +103,9 @@ class TimelineApi { if (visibility != null) { queryParams.addAll(_queryParams('', 'visibility', visibility)); } + if (withCoordinates != null) { + queryParams.addAll(_queryParams('', 'withCoordinates', withCoordinates)); + } if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } @@ -156,13 +162,16 @@ class TimelineApi { /// * [AssetVisibility] visibility: /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// + /// * [bool] withCoordinates: + /// Include location data in the response + /// /// * [bool] withPartners: /// Include assets shared by partners /// /// * [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? 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, withPartners: withPartners, withStacked: withStacked, ); + 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, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -210,12 +219,15 @@ class TimelineApi { /// * [AssetVisibility] visibility: /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// + /// * [bool] withCoordinates: + /// Include location data in the response + /// /// * [bool] withPartners: /// Include assets shared by partners /// /// * [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? withPartners, bool? withStacked, }) async { + 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 { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -256,6 +268,9 @@ class TimelineApi { if (visibility != null) { queryParams.addAll(_queryParams('', 'visibility', visibility)); } + if (withCoordinates != null) { + queryParams.addAll(_queryParams('', 'withCoordinates', withCoordinates)); + } if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } @@ -309,13 +324,16 @@ class TimelineApi { /// * [AssetVisibility] visibility: /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// + /// * [bool] withCoordinates: + /// Include location data in the response + /// /// * [bool] withPartners: /// Include assets shared by partners /// /// * [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? 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, withPartners: withPartners, withStacked: withStacked, ); + 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, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index 886b353f68..58032b7c51 100644 --- a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -21,8 +21,10 @@ class TimeBucketAssetResponseDto { this.isFavorite = const [], this.isImage = const [], this.isTrashed = const [], + this.latitude = const [], this.livePhotoVideoId = const [], this.localOffsetHours = const [], + this.longitude = const [], this.ownerId = const [], this.projectionType = const [], this.ratio = const [], @@ -55,12 +57,18 @@ class TimeBucketAssetResponseDto { /// Array indicating whether each asset is in the trash List isTrashed; + /// Array of latitude coordinates extracted from EXIF GPS data + List latitude; + /// Array of live photo video asset IDs (null for non-live photos) List livePhotoVideoId; /// Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective. List localOffsetHours; + /// Array of longitude coordinates extracted from EXIF GPS data + List longitude; + /// Array of owner IDs for each asset List ownerId; @@ -89,8 +97,10 @@ class TimeBucketAssetResponseDto { _deepEquality.equals(other.isFavorite, isFavorite) && _deepEquality.equals(other.isImage, isImage) && _deepEquality.equals(other.isTrashed, isTrashed) && + _deepEquality.equals(other.latitude, latitude) && _deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) && _deepEquality.equals(other.localOffsetHours, localOffsetHours) && + _deepEquality.equals(other.longitude, longitude) && _deepEquality.equals(other.ownerId, ownerId) && _deepEquality.equals(other.projectionType, projectionType) && _deepEquality.equals(other.ratio, ratio) && @@ -109,8 +119,10 @@ class TimeBucketAssetResponseDto { (isFavorite.hashCode) + (isImage.hashCode) + (isTrashed.hashCode) + + (latitude.hashCode) + (livePhotoVideoId.hashCode) + (localOffsetHours.hashCode) + + (longitude.hashCode) + (ownerId.hashCode) + (projectionType.hashCode) + (ratio.hashCode) + @@ -119,7 +131,7 @@ class TimeBucketAssetResponseDto { (visibility.hashCode); @override - String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]'; + String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, longitude=$longitude, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]'; Map toJson() { final json = {}; @@ -131,8 +143,10 @@ class TimeBucketAssetResponseDto { json[r'isFavorite'] = this.isFavorite; json[r'isImage'] = this.isImage; json[r'isTrashed'] = this.isTrashed; + json[r'latitude'] = this.latitude; json[r'livePhotoVideoId'] = this.livePhotoVideoId; json[r'localOffsetHours'] = this.localOffsetHours; + json[r'longitude'] = this.longitude; json[r'ownerId'] = this.ownerId; json[r'projectionType'] = this.projectionType; json[r'ratio'] = this.ratio; @@ -175,12 +189,18 @@ class TimeBucketAssetResponseDto { isTrashed: json[r'isTrashed'] is Iterable ? (json[r'isTrashed'] as Iterable).cast().toList(growable: false) : const [], + latitude: json[r'latitude'] is Iterable + ? (json[r'latitude'] as Iterable).cast().toList(growable: false) + : const [], livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable ? (json[r'livePhotoVideoId'] as Iterable).cast().toList(growable: false) : const [], localOffsetHours: json[r'localOffsetHours'] is Iterable ? (json[r'localOffsetHours'] as Iterable).cast().toList(growable: false) : const [], + longitude: json[r'longitude'] is Iterable + ? (json[r'longitude'] as Iterable).cast().toList(growable: false) + : const [], ownerId: json[r'ownerId'] is Iterable ? (json[r'ownerId'] as Iterable).cast().toList(growable: false) : const [], diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index 3cb77c0b33..497246e2a1 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -81,6 +81,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -110,6 +111,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -124,6 +126,7 @@ void main() { isTrashEnabled: true, isInLockedView: true, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -141,6 +144,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -156,6 +160,7 @@ void main() { isTrashEnabled: true, isInLockedView: true, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -171,6 +176,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -188,6 +194,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -203,6 +210,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -218,6 +226,7 @@ void main() { isTrashEnabled: true, isInLockedView: true, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -233,6 +242,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -248,6 +258,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -265,6 +276,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -280,6 +292,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -295,6 +308,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -312,6 +326,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -327,6 +342,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -342,6 +358,7 @@ void main() { isTrashEnabled: true, isInLockedView: true, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -359,6 +376,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -374,6 +392,7 @@ void main() { isTrashEnabled: false, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -391,6 +410,7 @@ void main() { isTrashEnabled: false, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -406,6 +426,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -423,6 +444,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -440,6 +462,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -457,6 +480,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -472,6 +496,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -489,6 +514,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -506,6 +532,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: album, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -520,6 +547,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -537,6 +565,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: album, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -552,6 +581,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: album, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -567,6 +597,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: album, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -581,12 +612,45 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); }); }); + + group('advancedTroubleshooting button', () { + test('should show when in advanced troubleshooting mode', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: true, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue); + }); + + test('should not show when not in advanced troubleshooting mode', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse); + }); + }); }); group('ActionButtonType.buildButton', () { @@ -602,6 +666,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); }); @@ -617,6 +682,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: album, + advancedTroubleshooting: false, source: ActionSource.timeline, ); final widget = buttonType.buildButton(contextWithAlbum); @@ -639,6 +705,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -658,6 +725,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: album, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -675,6 +743,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -693,6 +762,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); @@ -705,6 +775,7 @@ void main() { isTrashEnabled: true, isInLockedView: false, currentAlbum: null, + advancedTroubleshooting: false, source: ActionSource.timeline, ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7aac2025b0..f519ac9009 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2504,7 +2504,8 @@ "description": "This endpoint requires the `asset.download` permission." }, "put": { - "description": "Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission.", + "deprecated": true, + "description": "This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission.", "operationId": "replaceAsset", "parameters": [ { @@ -2566,12 +2567,14 @@ "api_key": [] } ], - "summary": "replaceAsset", + "summary": "Replace the asset with new file, without changing its id", "tags": [ - "Assets" + "Assets", + "Deprecated" ], "x-immich-lifecycle": { - "addedAt": "v1.106.0" + "addedAt": "v1.106.0", + "deprecatedAt": "v1.142.0" }, "x-immich-permission": "asset.replace" } @@ -8903,6 +8906,15 @@ "$ref": "#/components/schemas/AssetVisibility" } }, + { + "name": "withCoordinates", + "required": false, + "in": "query", + "description": "Include location data in the response", + "schema": { + "type": "boolean" + } + }, { "name": "withPartners", "required": false, @@ -9048,6 +9060,15 @@ "$ref": "#/components/schemas/AssetVisibility" } }, + { + "name": "withCoordinates", + "required": false, + "in": "query", + "description": "Include location data in the response", + "schema": { + "type": "boolean" + } + }, { "name": "withPartners", "required": false, @@ -17137,6 +17158,14 @@ }, "type": "array" }, + "latitude": { + "description": "Array of latitude coordinates extracted from EXIF GPS data", + "items": { + "nullable": true, + "type": "number" + }, + "type": "array" + }, "livePhotoVideoId": { "description": "Array of live photo video asset IDs (null for non-live photos)", "items": { @@ -17152,6 +17181,14 @@ }, "type": "array" }, + "longitude": { + "description": "Array of longitude coordinates extracted from EXIF GPS data", + "items": { + "nullable": true, + "type": "number" + }, + "type": "array" + }, "ownerId": { "description": "Array of owner IDs for each asset", "items": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c50efb7d38..3bd9de2e77 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1576,10 +1576,14 @@ export type TimeBucketAssetResponseDto = { isImage: boolean[]; /** Array indicating whether each asset is in the trash */ isTrashed: boolean[]; + /** Array of latitude coordinates extracted from EXIF GPS data */ + latitude?: (number | null)[]; /** Array of live photo video asset IDs (null for non-live photos) */ livePhotoVideoId: (string | null)[]; /** Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective. */ localOffsetHours: number[]; + /** Array of longitude coordinates extracted from EXIF GPS data */ + longitude?: (number | null)[]; /** Array of owner IDs for each asset */ ownerId: string[]; /** Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL") */ @@ -2379,7 +2383,7 @@ export function downloadAsset({ id, key, slug }: { })); } /** - * replaceAsset + * Replace the asset with new file, without changing its id */ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: { id: string; @@ -4310,7 +4314,7 @@ export function tagAssets({ id, bulkIdsDto }: { /** * This endpoint requires the `asset.read` permission. */ -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; @@ -4322,6 +4326,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers timeBucket: string; userId?: string; visibility?: AssetVisibility; + withCoordinates?: boolean; withPartners?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -4340,6 +4345,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers timeBucket, userId, visibility, + withCoordinates, withPartners, withStacked }))}`, { @@ -4349,7 +4355,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers /** * This endpoint requires the `asset.read` permission. */ -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; @@ -4360,6 +4366,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per tagId?: string; userId?: string; visibility?: AssetVisibility; + withCoordinates?: boolean; withPartners?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -4377,6 +4384,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per tagId, userId, visibility, + withCoordinates, withPartners, withStacked }))}`, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b184f398ff..0fb05f0bb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ importers: specifier: ^4.0.8 version: 4.0.8 devDependencies: - '@eslint/eslintrc': - specifier: ^3.1.0 - version: 3.3.1 '@eslint/js': specifier: ^9.8.0 version: 9.33.0 @@ -70,7 +67,7 @@ importers: version: 22.18.1 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -118,10 +115,10 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@7.1.2(@types/node@22.18.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) yaml: specifier: ^2.3.1 version: 2.8.1 @@ -149,12 +146,6 @@ importers: autoprefixer: specifier: ^10.4.17 version: 10.4.21(postcss@8.5.6) - classnames: - specifier: ^2.3.2 - version: 2.5.1 - clsx: - specifier: ^2.0.0 - version: 2.1.1 docusaurus-lunr-search: specifier: ^3.3.2 version: 3.6.0(@docusaurus/core@3.8.1(@mdx-js/react@3.1.0(@types/react@19.1.12)(react@18.3.1))(acorn@8.15.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -204,9 +195,6 @@ importers: e2e: devDependencies: - '@eslint/eslintrc': - specifier: ^3.1.0 - version: 3.3.1 '@eslint/js': specifier: ^9.8.0 version: 9.33.0 @@ -240,9 +228,6 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.3 - '@vitest/coverage-v8': - specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) eslint: specifier: ^9.14.0 version: 9.33.0(jiti@2.5.1) @@ -328,9 +313,6 @@ importers: '@nestjs/core': specifier: ^11.0.4 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/event-emitter': - specifier: ^3.0.0 - version: 3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/platform-express': specifier: ^11.0.4 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) @@ -349,9 +331,6 @@ importers: '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 - '@opentelemetry/auto-instrumentations-node': - specifier: ^0.62.0 - version: 0.62.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13) '@opentelemetry/context-async-hooks': specifier: ^2.0.0 version: 2.0.1(@opentelemetry/api@1.9.0) @@ -541,9 +520,6 @@ importers: thumbhash: specifier: ^0.1.1 version: 0.1.1 - typeorm: - specifier: ^0.3.17 - version: 0.3.25(ioredis@5.7.0)(pg@8.16.3)(reflect-metadata@0.2.2) ua-parser-js: specifier: ^2.0.0 version: 2.0.4(encoding@0.1.13) @@ -554,9 +530,6 @@ importers: specifier: ^13.12.0 version: 13.15.15 devDependencies: - '@eslint/eslintrc': - specifier: ^3.1.0 - version: 3.3.1 '@eslint/js': specifier: ^9.8.0 version: 9.33.0 @@ -572,12 +545,6 @@ importers: '@swc/core': specifier: ^1.4.14 version: 1.13.3(@swc/helpers@0.5.17) - '@testcontainers/postgresql': - specifier: ^11.0.0 - version: 11.5.1 - '@testcontainers/redis': - specifier: ^11.0.0 - version: 11.5.1 '@types/archiver': specifier: ^6.0.0 version: 6.0.3 @@ -650,9 +617,6 @@ importers: '@vitest/coverage-v8': specifier: ^3.0.0 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) - canvas: - specifier: 2.11.2 - version: 2.11.2(encoding@0.1.13) eslint: specifier: ^9.14.0 version: 9.33.0(jiti@2.5.1) @@ -671,9 +635,6 @@ importers: mock-fs: specifier: ^5.2.0 version: 5.5.0 - node-addon-api: - specifier: ^8.3.1 - version: 8.5.0 node-gyp: specifier: ^11.2.0 version: 11.3.0 @@ -686,12 +647,6 @@ importers: prettier-plugin-organize-imports: specifier: ^4.0.0 version: 4.2.0(prettier@3.6.2)(typescript@5.9.2) - rimraf: - specifier: ^6.0.0 - version: 6.0.1 - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 sql-formatter: specifier: ^15.0.0 version: 15.6.6 @@ -704,9 +659,6 @@ importers: testcontainers: specifier: ^11.0.0 version: 11.5.1 - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -716,9 +668,6 @@ importers: unplugin-swc: specifier: ^1.4.5 version: 1.5.5(@swc/core@1.13.3(@swc/helpers@0.5.17))(rollup@4.46.3) - utimes: - specifier: ^5.2.1 - version: 5.2.1(encoding@0.1.13) vite-tsconfig-paths: specifier: ^5.0.0 version: 5.1.4(typescript@5.9.2)(vite@7.1.2(@types/node@22.18.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) @@ -735,8 +684,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.24.0 - version: 0.24.1(@internationalized/date@3.8.2)(svelte@5.35.5) + specifier: ^0.27.1 + version: 0.27.1(@internationalized/date@3.8.2)(svelte@5.35.5) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -831,9 +780,6 @@ importers: specifier: ^0.1.1 version: 0.1.1 devDependencies: - '@eslint/eslintrc': - specifier: ^3.1.0 - version: 3.3.1 '@eslint/js': specifier: ^9.18.0 version: 9.33.0 @@ -891,9 +837,6 @@ importers: '@vitest/coverage-v8': specifier: ^3.0.0 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) - autoprefixer: - specifier: ^10.4.17 - version: 10.4.21(postcss@8.5.6) dotenv: specifier: ^17.0.0 version: 17.2.1 @@ -948,9 +891,6 @@ importers: tailwindcss: specifier: ^4.1.7 version: 4.1.12 - tslib: - specifier: ^2.6.2 - version: 2.8.1 typescript: specifier: ^5.8.3 version: 5.9.2 @@ -2649,8 +2589,8 @@ packages: cpu: [x64] os: [win32] - '@immich/ui@0.24.1': - resolution: {integrity: sha512-phJ9BHV0+OnKsxXD+5+Te5Amnb1N4ExYpRGSJPYFqutd5WXeN7kZGKZXd3CfcQ1e31SXRy4DsHSGdM1pY7AUgA==} + '@immich/ui@0.27.1': + resolution: {integrity: sha512-d/LqCpFZwaZ6Vp2wz+DkhMirMle2zL/y4SHyKLmA0QI6pwz+yZaym6DlYkx3ZPKlN10/ugeHi58fXdlMxJiuKA==} peerDependencies: svelte: ^5.0.0 @@ -3051,12 +2991,6 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/event-emitter@3.0.1': - resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} - peerDependencies: - '@nestjs/common': ^10.0.0 || ^11.0.0 - '@nestjs/core': ^10.0.0 || ^11.0.0 - '@nestjs/mapped-types@2.1.0': resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} peerDependencies: @@ -3176,13 +3110,6 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/auto-instrumentations-node@0.62.1': - resolution: {integrity: sha512-FmPlWS7Dg6E3kP0vv19Pyhq3sqSi8tyn8IZh2RV73UsrcEZeQ3gUTf2Ar8iPRgbsxTukQHRoMGcaCVBsFVRVPw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.4.1 - '@opentelemetry/core': ^2.0.0 - '@opentelemetry/context-async-hooks@2.0.1': resolution: {integrity: sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3267,102 +3194,6 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-amqplib@0.50.0': - resolution: {integrity: sha512-kwNs/itehHG/qaQBcVrLNcvXVPW0I4FCOVtw3LHMLdYIqD7GJ6Yv2nX+a4YHjzbzIeRYj8iyMp0Bl7tlkidq5w==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-aws-lambda@0.54.0': - resolution: {integrity: sha512-uiYI+kcMUJ/H9cxAwB8c9CaG8behLRgcYSOEA8M/tMQ54Y1ZmzAuEE3QKOi21/s30x5Q+by9g7BwiVfDtqzeMA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-aws-sdk@0.57.0': - resolution: {integrity: sha512-RfbyjaeZzX3mPhuaRHlSAQyfX3skfeWOl30jrqSXtE9k0DPdnIqpHhdYS0C/DEDuZbwTmruVJ4cUwMBw5Z6FAg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-bunyan@0.49.0': - resolution: {integrity: sha512-ky5Am1y6s3Ex/3RygHxB/ZXNG07zPfg9Z6Ora+vfeKcr/+I6CJbWXWhSBJor3gFgKN3RvC11UWVURnmDpBS6Pg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-cassandra-driver@0.49.0': - resolution: {integrity: sha512-BNIvqldmLkeikfI5w5Rlm9vG5NnQexfPoxOgEMzfDVOEF+vS6351I6DzWLLgWWR9CNF/jQJJi/lr6am2DLp0Rw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-connect@0.47.0': - resolution: {integrity: sha512-pjenvjR6+PMRb6/4X85L4OtkQCootgb/Jzh/l/Utu3SJHBid1F+gk9sTGU2FWuhhEfV6P7MZ7BmCdHXQjgJ42g==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-cucumber@0.18.1': - resolution: {integrity: sha512-gTfT7AuA0UH0TvqWOXnyr2KCv7mvZsOUmqCrtnU/RDcZ9J3nIX4OBfl7VVXE0fJlLqP7KIDggQ8O9g7rmaVLhA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - - '@opentelemetry/instrumentation-dataloader@0.21.1': - resolution: {integrity: sha512-hNAm/bwGawLM8VDjKR0ZUDJ/D/qKR3s6lA5NV+btNaPVm2acqhPcT47l2uCVi+70lng2mywfQncor9v8/ykuyw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-dns@0.47.0': - resolution: {integrity: sha512-775fOnewWkTF4iXMGKgwvOGqEmPrU1PZpXjjqvTrEErYBJe7Fz1WlEeUStHepyKOdld7Ghv7TOF/kE3QDctvrg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-express@0.52.0': - resolution: {integrity: sha512-W7pizN0Wh1/cbNhhTf7C62NpyYw7VfCFTYg0DYieSTrtPBT1vmoSZei19wfKLnrMsz3sHayCg0HxCVL2c+cz5w==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-fastify@0.48.0': - resolution: {integrity: sha512-3zQlE/DoVfVH6/ycuTv7vtR/xib6WOa0aLFfslYcvE62z0htRu/ot8PV/zmMZfnzpTQj8S/4ULv36R6UIbpJIg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-fs@0.23.0': - resolution: {integrity: sha512-Puan+QopWHA/KNYvDfOZN6M/JtF6buXEyD934vrb8WhsX1/FuM7OtoMlQyIqAadnE8FqqDL4KDPiEfCQH6pQcQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-generic-pool@0.47.0': - resolution: {integrity: sha512-UfHqf3zYK+CwDwEtTjaD12uUqGGTswZ7ofLBEdQ4sEJp9GHSSJMQ2hT3pgBxyKADzUdoxQAv/7NqvL42ZI+Qbw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-graphql@0.51.0': - resolution: {integrity: sha512-LchkOu9X5DrXAnPI1+Z06h/EH/zC7D6sA86hhPrk3evLlsJTz0grPrkL/yUJM9Ty0CL/y2HSvmWQCjbJEz/ADg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-grpc@0.203.0': - resolution: {integrity: sha512-Qmjx2iwccHYRLoE4RFS46CvQE9JG9Pfeae4EPaNZjvIuJxb/pZa2R9VWzRlTehqQWpAvto/dGhtkw8Tv+o0LTg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-hapi@0.50.0': - resolution: {integrity: sha512-5xGusXOFQXKacrZmDbpHQzqYD1gIkrMWuwvlrEPkYOsjUqGUjl1HbxCsn5Y9bUXOCgP1Lj6A4PcKt1UiJ2MujA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-http@0.203.0': resolution: {integrity: sha512-y3uQAcCOAwnO6vEuNVocmpVzG3PER6/YZqbPbbffDdJ9te5NkHEkfSMNzlC3+v7KlE+WinPGc3N7MR30G1HY2g==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3375,138 +3206,18 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-kafkajs@0.13.0': - resolution: {integrity: sha512-FPQyJsREOaGH64hcxlzTsIEQC4DYANgTwHjiB7z9lldmvua1LRMVn3/FfBlzXoqF179B0VGYviz6rn75E9wsDw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-knex@0.48.0': - resolution: {integrity: sha512-V5wuaBPv/lwGxuHjC6Na2JFRjtPgstw19jTFl1B1b6zvaX8zVDYUDaR5hL7glnQtUSCMktPttQsgK4dhXpddcA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-koa@0.51.0': - resolution: {integrity: sha512-XNLWeMTMG1/EkQBbgPYzCeBD0cwOrfnn8ao4hWgLv0fNCFQu1kCsJYygz2cvKuCs340RlnG4i321hX7R8gj3Rg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-lru-memoizer@0.48.0': - resolution: {integrity: sha512-KUW29wfMlTPX1wFz+NNrmE7IzN7NWZDrmFWHM/VJcmFEuQGnnBuTIdsP55CnBDxKgQ/qqYFp4udQFNtjeFosPw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-memcached@0.47.0': - resolution: {integrity: sha512-vXDs/l4hlWy1IepPG1S6aYiIZn+tZDI24kAzwKKJmR2QEJRL84PojmALAEJGazIOLl/VdcCPZdMb0U2K0VzojA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mongodb@0.56.0': - resolution: {integrity: sha512-YG5IXUUmxX3Md2buVMvxm9NWlKADrnavI36hbJsihqqvBGsWnIfguf0rUP5Srr0pfPqhQjUP+agLMsvu0GmUpA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mongoose@0.50.0': - resolution: {integrity: sha512-Am8pk1Ct951r4qCiqkBcGmPIgGhoDiFcRtqPSLbJrUZqEPUsigjtMjoWDRLG1Ki1NHgOF7D0H7d+suWz1AAizw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mysql2@0.50.0': - resolution: {integrity: sha512-PoOMpmq73rOIE3nlTNLf3B1SyNYGsp7QXHYKmeTZZnJ2Ou7/fdURuOhWOI0e6QZ5gSem18IR1sJi6GOULBQJ9g==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-mysql@0.49.0': - resolution: {integrity: sha512-QU9IUNqNsrlfE3dJkZnFHqLjlndiU39ll/YAAEvWE40sGOCi9AtOF6rmEGzJ1IswoZ3oyePV7q2MP8SrhJfVAA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-nestjs-core@0.49.0': resolution: {integrity: sha512-1R/JFwdmZIk3T/cPOCkVvFQeKYzbbUvDxVH3ShXamUwBlGkdEu5QJitlRMyVNZaHkKZKWgYrBarGQsqcboYgaw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-net@0.47.0': - resolution: {integrity: sha512-csoJ++Njpf7C09JH+0HNGenuNbDZBqO1rFhMRo6s0rAmJwNh9zY3M/urzptmKlqbKnf4eH0s+CKHy/+M8fbFsQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-oracledb@0.29.0': - resolution: {integrity: sha512-2aHLiJdkyiUbooIUm7FaZf+O4jyqEl+RfFpgud1dxT87QeeYM216wi+xaMNzsb5yKtRBqbA3qeHBCyenYrOZwA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pg@0.56.0': resolution: {integrity: sha512-A/J4SlGX8Y0Wwp7Y66fsNCFT/1h9lmBzqwTnfWW/bULtcKFqkQfqhs3G8+4cRxX02UI2z7T1aW5bsyc6QSYc1Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pino@0.50.0': - resolution: {integrity: sha512-Pi0cWGp4f2gresq2xqef4IsuunLdebJ9n9tZxytDz2ci4euIfW36ILpszQmRNhwCVDCZLmUgGDKZGj4PXyPd0w==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-redis@0.51.0': - resolution: {integrity: sha512-uL/GtBA0u72YPPehwOvthAe+Wf8k3T+XQPBssJmTYl6fzuZjNq8zTfxVFhl9nRFjFVEe+CtiYNT0Q3AyqW1Z0A==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-restify@0.49.0': - resolution: {integrity: sha512-tsGZZhS4mVZH7omYxw5jpsrD3LhWizqWc0PYtAnzpFUvL5ZINHE+cm57bssTQ2AK/GtZMxu9LktwCvIIf3dSmw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-router@0.48.0': - resolution: {integrity: sha512-Wixrc8CchuJojXpaS/dCQjFOMc+3OEil1H21G+WLYQb8PcKt5kzW9zDBT19nyjjQOx/D/uHPfgbrT+Dc7cfJ9w==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-runtime-node@0.17.1': - resolution: {integrity: sha512-c1FlAk+bB2uF9a8YneGmNPTl7c/xVaan4mmWvbkWcOmH/ipKqR1LaKUlz/BMzLrJLjho1EJlG2NrS2w2Arg+nw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-socket.io@0.50.0': - resolution: {integrity: sha512-6JN6lnKN9ZuZtZdMQIR+no1qHzQvXSZUsNe3sSWMgqmNRyEXuDUWBIyKKeG0oHRHtR4xE4QhJyD4D5kKRPWZFA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-tedious@0.22.0': - resolution: {integrity: sha512-XrrNSUCyEjH1ax9t+Uo6lv0S2FCCykcF7hSxBMxKf7Xn0bPRxD3KyFUZy25aQXzbbbUHhtdxj3r2h88SfEM3aA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/instrumentation-undici@0.14.0': - resolution: {integrity: sha512-2HN+7ztxAReXuxzrtA3WboAKlfP5OsPA57KQn2AdYZbJ3zeRPcLXyW4uO/jpLE6PLm0QRtmeGCmfYpqRlwgSwg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.7.0 - - '@opentelemetry/instrumentation-winston@0.48.1': - resolution: {integrity: sha512-XyOuVwdziirHHYlsw+BWrvdI/ymjwnexupKA787zQQ+D5upaE/tseZxjfQa7+t4+FdVLxHICaMTmkSD4yZHpzQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.203.0': resolution: {integrity: sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3531,12 +3242,6 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagation-utils@0.31.3': - resolution: {integrity: sha512-ZI6LKjyo+QYYZY5SO8vfoCQ9A69r1/g+pyjvtu5RSK38npINN1evEmwqbqhbg2CdcIK3a4PN6pDAJz/yC5/gAA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - '@opentelemetry/propagator-b3@2.0.1': resolution: {integrity: sha512-Hc09CaQ8Tf5AGLmf449H726uRoBNGPBL4bjr7AnnUpzWMvhdn61F78z9qb6IqB737TffBsokGAK1XykFEZ1igw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3553,36 +3258,6 @@ packages: resolution: {integrity: sha512-4Wc0AWURII2cfXVVoZ6vDqK+s5n4K5IssdrlVrvGsx6OEOKdghKtJZqXAHWFiZv4nTDLH2/2fldjIHY8clMOjQ==} engines: {node: ^18.19.0 || >=20.6.0} - '@opentelemetry/resource-detector-alibaba-cloud@0.31.3': - resolution: {integrity: sha512-I556LHcLVsBXEgnbPgQISP/JezDt5OfpgOaJNR1iVJl202r+K145OSSOxnH5YOc/KvrydBD0FOE03F7x0xnVTw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - - '@opentelemetry/resource-detector-aws@2.3.0': - resolution: {integrity: sha512-PkD/lyXG3B3REq1Y6imBLckljkJYXavtqGYSryAeJYvGOf5Ds3doR+BCGjmKeF6ObAtI5MtpBeUStTDtGtBsWA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - - '@opentelemetry/resource-detector-azure@0.10.0': - resolution: {integrity: sha512-5cNAiyPBg53Uxe/CW7hsCq8HiKNAUGH+gi65TtgpzSR9bhJG4AEbuZhbJDFwe97tn2ifAD1JTkbc/OFuaaFWbA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - - '@opentelemetry/resource-detector-container@0.7.3': - resolution: {integrity: sha512-SK+xUFw6DKYbQniaGmIFsFxAZsr8RpRSRWxKi5/ZJAoqqPnjcyGI/SeUx8zzPk4XLO084zyM4pRHgir0hRTaSQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - - '@opentelemetry/resource-detector-gcp@0.37.0': - resolution: {integrity: sha512-LGpJBECIMsVKhiulb4nxUw++m1oF4EiDDPmFGW2aqYaAF0oUvJNv8Z/55CAzcZ7SxvlTgUwzewXDBsuCup7iqw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - '@opentelemetry/resources@2.0.1': resolution: {integrity: sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4007,9 +3682,6 @@ packages: peerDependencies: socket.io-adapter: ^2.5.4 - '@sqltools/formatter@1.2.5': - resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} - '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -4304,12 +3976,6 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@testcontainers/postgresql@11.5.1': - resolution: {integrity: sha512-6P1QYIKRkktSVwTuwU0Pke5WbXTkvpLleyQcgknJPbZwhaIsCrhnbZlVzj2g/e+Nf9Lmdy1F2OAai+vUrBq0AQ==} - - '@testcontainers/redis@11.5.1': - resolution: {integrity: sha512-ThGaUPUCFW4Vwmx6kfPYhhTQjq/3UXJQrU/xxiYLqgvFJNtvtYlWmzXrwORLhPkkqnoFUnfFaX3u9u1GnrlDkw==} - '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -4373,9 +4039,6 @@ packages: '@types/async-lock@1.4.2': resolution: {integrity: sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==} - '@types/aws-lambda@8.10.150': - resolution: {integrity: sha512-AX+AbjH/rH5ezX1fbK8onC/a+HyQHo7QGmvoxAE42n22OsciAxvZoZNEr22tbXs8WfP1nIsBjKDpgPm3HjOZbA==} - '@types/bcrypt@6.0.0': resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} @@ -4388,9 +4051,6 @@ packages: '@types/braces@3.0.5': resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} - '@types/bunyan@1.8.11': - resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} - '@types/byte-size@8.1.2': resolution: {integrity: sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==} @@ -4573,9 +4233,6 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - '@types/memcached@2.2.10': - resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} - '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -4594,9 +4251,6 @@ packages: '@types/multer@2.0.0': resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} - '@types/mysql@2.15.27': - resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} - '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} @@ -4624,9 +4278,6 @@ packages: '@types/oidc-provider@9.1.2': resolution: {integrity: sha512-JAreXkbWsZR72Gt3eigG652wq1qBcjhuy421PXU2a8PS0mM00XlG+UdXbM/QPihM3ko0YF8cwvt0H2kacXGcsg==} - '@types/oracledb@6.5.2': - resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} - '@types/parse5@5.0.3': resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} @@ -4717,9 +4368,6 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - '@types/tedious@4.0.14': - resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} - '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} @@ -5061,10 +4709,6 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - ansis@3.17.0: - resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} - engines: {node: '>=14'} - ansis@4.1.0: resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} engines: {node: '>=14'} @@ -5076,10 +4720,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - app-root-path@3.1.0: - resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} - engines: {node: '>= 6.0.0'} - append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} @@ -5277,9 +4917,6 @@ packages: big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -5542,9 +5179,6 @@ packages: class-validator@0.14.2: resolution: {integrity: sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==} - classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - clean-css@5.3.3: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} engines: {node: '>= 10.0'} @@ -6033,9 +5667,6 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} - dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -6087,14 +5718,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - dedent@1.6.0: - resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -6310,10 +5933,6 @@ packages: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - dotenv@17.2.1: resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} engines: {node: '>=12'} @@ -6662,9 +6281,6 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter2@6.4.9: - resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} - eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -6957,14 +6573,6 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. - gaxios@6.7.1: - resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} - engines: {node: '>=14'} - - gcp-metadata@6.1.1: - resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} - engines: {node: '>=14'} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -7085,10 +6693,6 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - google-logging-utils@0.0.2: - resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} - engines: {node: '>=14'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -7758,9 +7362,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-bigint@1.0.0: - resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -10129,11 +9730,6 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rimraf@6.0.1: - resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} - engines: {node: 20 || >=22} - hasBin: true - robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -10308,10 +9904,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sha.js@2.4.11: - resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} - hasBin: true - shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} @@ -10394,6 +9986,10 @@ packages: simple-get@3.1.1: resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} + simple-icons@15.14.0: + resolution: {integrity: sha512-eTBZiiwDFN8RPkcmHKoUz1+sckeqNQXv5ujQcgQddDzp3xuDIFWeZh/i0oEv1StOPsf9NPMC0gTBxUzhPqHzag==} + engines: {node: '>=0.12.18'} + simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} @@ -10507,10 +10103,6 @@ packages: resolution: {integrity: sha512-bZydXEXhaNDQBr8xYHC3a8thwcaMuTBp0CkKGjwGYDsIB26tnlWeWPwJtSQ0TEwiJcz9iJJON5mFPkx7XroHcg==} hasBin: true - sql-highlight@6.1.0: - resolution: {integrity: sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==} - engines: {node: '>=14'} - srcset@4.0.0: resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} engines: {node: '>=12'} @@ -10784,8 +10376,8 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} - tailwind-variants@2.1.0: - resolution: {integrity: sha512-82m0eRex0z6A3GpvfoTCpHr+wWJmbecfVZfP3mqLoDxeya5tN4mYJQZwa5Aw1hRZTedwpu1D2JizYenoEdyD8w==} + tailwind-variants@3.1.1: + resolution: {integrity: sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==} engines: {node: '>=16.x', pnpm: '>=7.x'} peerDependencies: tailwind-merge: '>=3.0.0' @@ -11081,65 +10673,6 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typeorm@0.3.25: - resolution: {integrity: sha512-fTKDFzWXKwAaBdEMU4k661seZewbNYET4r1J/z3Jwf+eAvlzMVpTLKAVcAzg75WwQk7GDmtsmkZ5MfkmXCiFWg==} - engines: {node: '>=16.13.0'} - hasBin: true - peerDependencies: - '@google-cloud/spanner': ^5.18.0 || ^6.0.0 || ^7.0.0 - '@sap/hana-client': ^2.12.25 - better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - hdb-pool: ^0.1.6 - ioredis: ^5.0.4 - mongodb: ^5.8.0 || ^6.0.0 - mssql: ^9.1.1 || ^10.0.1 || ^11.0.1 - mysql2: ^2.2.5 || ^3.0.1 - oracledb: ^6.3.0 - pg: ^8.5.1 - pg-native: ^3.0.0 - pg-query-stream: ^4.0.0 - redis: ^3.1.1 || ^4.0.0 - reflect-metadata: ^0.1.14 || ^0.2.0 - sql.js: ^1.4.0 - sqlite3: ^5.0.3 - ts-node: ^10.7.0 - typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 - peerDependenciesMeta: - '@google-cloud/spanner': - optional: true - '@sap/hana-client': - optional: true - better-sqlite3: - optional: true - hdb-pool: - optional: true - ioredis: - optional: true - mongodb: - optional: true - mssql: - optional: true - mysql2: - optional: true - oracledb: - optional: true - pg: - optional: true - pg-native: - optional: true - pg-query-stream: - optional: true - redis: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - ts-node: - optional: true - typeorm-aurora-data-api-driver: - optional: true - typescript-eslint@8.39.1: resolution: {integrity: sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -14162,13 +13695,14 @@ snapshots: '@img/sharp-win32-x64@0.34.3': optional: true - '@immich/ui@0.24.1(@internationalized/date@3.8.2)(svelte@5.35.5)': + '@immich/ui@0.27.1(@internationalized/date@3.8.2)(svelte@5.35.5)': dependencies: '@mdi/js': 7.4.47 bits-ui: 2.9.4(@internationalized/date@3.8.2)(svelte@5.35.5) + simple-icons: 15.14.0 svelte: 5.35.5 tailwind-merge: 3.3.1 - tailwind-variants: 2.1.0(tailwind-merge@3.3.1)(tailwindcss@4.1.12) + tailwind-variants: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.12) tailwindcss: 4.1.12 transitivePeerDependencies: - '@internationalized/date' @@ -14649,12 +14183,6 @@ snapshots: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/websockets': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': - dependencies: - '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) - eventemitter2: 6.4.9 - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -14790,62 +14318,6 @@ snapshots: '@opentelemetry/api@1.9.0': {} - '@opentelemetry/auto-instrumentations-node@0.62.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-amqplib': 0.50.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-aws-lambda': 0.54.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-aws-sdk': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-bunyan': 0.49.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-cassandra-driver': 0.49.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-connect': 0.47.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-cucumber': 0.18.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-dataloader': 0.21.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-dns': 0.47.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-express': 0.52.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fastify': 0.48.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fs': 0.23.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-generic-pool': 0.47.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-graphql': 0.51.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-grpc': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-hapi': 0.50.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-http': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-ioredis': 0.51.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-kafkajs': 0.13.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-knex': 0.48.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-koa': 0.51.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-lru-memoizer': 0.48.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-memcached': 0.47.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongodb': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongoose': 0.50.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql': 0.49.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql2': 0.50.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-nestjs-core': 0.49.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-net': 0.47.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-oracledb': 0.29.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-pg': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-pino': 0.50.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-redis': 0.51.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-restify': 0.49.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-router': 0.48.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-runtime-node': 0.17.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-socket.io': 0.50.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-tedious': 0.22.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-undici': 0.14.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-winston': 0.48.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-alibaba-cloud': 0.31.3(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-aws': 2.3.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-azure': 0.10.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-container': 0.7.3(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-gcp': 0.37.0(@opentelemetry/api@1.9.0)(encoding@0.1.13) - '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - encoding - - supports-color - '@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -14965,140 +14437,6 @@ snapshots: '@opentelemetry/api': 1.9.0 systeminformation: 5.23.8 - '@opentelemetry/instrumentation-amqplib@0.50.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-aws-lambda@0.54.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - '@types/aws-lambda': 8.10.150 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-aws-sdk@0.57.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagation-utils': 0.31.3(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-bunyan@0.49.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.203.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@types/bunyan': 1.8.11 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-cassandra-driver@0.49.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-connect@0.47.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - '@types/connect': 3.4.38 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-cucumber@0.18.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-dataloader@0.21.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-dns@0.47.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-express@0.52.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-fastify@0.48.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-fs@0.23.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-generic-pool@0.47.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-graphql@0.51.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-grpc@0.203.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-hapi@0.50.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - '@opentelemetry/instrumentation-http@0.203.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -15118,82 +14456,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-kafkajs@0.13.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-knex@0.48.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-koa@0.51.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-lru-memoizer@0.48.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-memcached@0.47.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - '@types/memcached': 2.2.10 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mongodb@0.56.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mongoose@0.50.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mysql2@0.50.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - '@opentelemetry/sql-common': 0.41.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-mysql@0.49.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - '@types/mysql': 2.15.27 - transitivePeerDependencies: - - supports-color - '@opentelemetry/instrumentation-nestjs-core@0.49.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -15202,23 +14464,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-net@0.47.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-oracledb@0.29.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - '@types/oracledb': 6.5.2 - transitivePeerDependencies: - - supports-color - '@opentelemetry/instrumentation-pg@0.56.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -15231,81 +14476,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pino@0.50.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.203.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-redis@0.51.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/redis-common': 0.38.0 - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-restify@0.49.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-router@0.48.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-runtime-node@0.17.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-socket.io@0.50.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-tedious@0.22.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - '@types/tedious': 4.0.14 - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-undici@0.14.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/instrumentation-winston@0.48.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.203.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - '@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -15340,10 +14510,6 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) protobufjs: 7.5.4 - '@opentelemetry/propagation-utils@0.31.3(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -15356,45 +14522,6 @@ snapshots: '@opentelemetry/redis-common@0.38.0': {} - '@opentelemetry/resource-detector-alibaba-cloud@0.31.3(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - - '@opentelemetry/resource-detector-aws@2.3.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - - '@opentelemetry/resource-detector-azure@0.10.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - - '@opentelemetry/resource-detector-container@0.7.3(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - - '@opentelemetry/resource-detector-gcp@0.37.0(@opentelemetry/api@1.9.0)(encoding@0.1.13)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.36.0 - gcp-metadata: 6.1.1(encoding@0.1.13) - transitivePeerDependencies: - - encoding - - supports-color - '@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -15777,8 +14904,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@sqltools/formatter@1.2.5': {} - '@standard-schema/spec@1.0.0': {} '@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)': @@ -16069,20 +15194,6 @@ snapshots: tailwindcss: 4.1.12 vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - '@testcontainers/postgresql@11.5.1': - dependencies: - testcontainers: 11.5.1 - transitivePeerDependencies: - - bare-buffer - - supports-color - - '@testcontainers/redis@11.5.1': - dependencies: - testcontainers: 11.5.1 - transitivePeerDependencies: - - bare-buffer - - supports-color - '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 @@ -16161,8 +15272,6 @@ snapshots: '@types/async-lock@1.4.2': {} - '@types/aws-lambda@8.10.150': {} - '@types/bcrypt@6.0.0': dependencies: '@types/node': 22.18.1 @@ -16178,10 +15287,6 @@ snapshots: '@types/braces@3.0.5': {} - '@types/bunyan@1.8.11': - dependencies: - '@types/node': 22.18.1 - '@types/byte-size@8.1.2': {} '@types/chai@5.2.2': @@ -16403,10 +15508,6 @@ snapshots: '@types/mdx@2.0.13': {} - '@types/memcached@2.2.10': - dependencies: - '@types/node': 22.18.1 - '@types/methods@1.1.4': {} '@types/micromatch@4.0.9': @@ -16425,10 +15526,6 @@ snapshots: dependencies: '@types/express': 5.0.3 - '@types/mysql@2.15.27': - dependencies: - '@types/node': 22.18.1 - '@types/node-fetch@2.6.12': dependencies: '@types/node': 22.18.1 @@ -16467,10 +15564,6 @@ snapshots: '@types/koa': 3.0.0 '@types/node': 22.18.1 - '@types/oracledb@6.5.2': - dependencies: - '@types/node': 22.18.1 - '@types/parse5@5.0.3': {} '@types/pg-pool@2.0.6': @@ -16597,10 +15690,6 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 - '@types/tedious@4.0.14': - dependencies: - '@types/node': 22.18.1 - '@types/through@0.0.33': dependencies: '@types/node': 22.18.1 @@ -16739,6 +15828,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.3 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 @@ -17048,8 +16156,6 @@ snapshots: ansi-styles@6.2.1: {} - ansis@3.17.0: {} - ansis@4.1.0: {} any-promise@1.3.0: {} @@ -17059,8 +16165,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - app-root-path@3.1.0: {} - append-field@1.0.0: {} aproba@2.0.0: {} @@ -17253,8 +16357,6 @@ snapshots: big.js@5.2.2: {} - bignumber.js@9.3.1: {} - binary-extensions@2.3.0: {} bits-ui@2.9.4(@internationalized/date@3.8.2)(svelte@5.35.5): @@ -17487,6 +16589,7 @@ snapshots: transitivePeerDependencies: - encoding - supports-color + optional: true ccount@2.0.1: {} @@ -17586,8 +16689,6 @@ snapshots: libphonenumber-js: 1.12.9 validator: 13.15.15 - classnames@2.5.1: {} - clean-css@5.3.3: dependencies: source-map: 0.6.1 @@ -18093,8 +17194,6 @@ snapshots: whatwg-url: 14.2.0 optional: true - dayjs@1.11.13: {} - debounce@1.2.1: {} debounce@2.2.0: {} @@ -18122,13 +17221,12 @@ snapshots: decompress-response@4.2.1: dependencies: mimic-response: 2.1.0 + optional: true decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 - dedent@1.6.0: {} - deep-eql@5.0.2: {} deep-equal@1.0.1: {} @@ -18451,8 +17549,6 @@ snapshots: dependencies: is-obj: 2.0.0 - dotenv@16.6.1: {} - dotenv@17.2.1: {} dunder-proto@1.0.1: @@ -18944,8 +18040,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter2@6.4.9: {} - eventemitter3@4.0.7: {} events@3.3.0: {} @@ -19328,26 +18422,6 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 - gaxios@6.7.1(encoding@0.1.13): - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6 - is-stream: 2.0.1 - node-fetch: 2.7.0(encoding@0.1.13) - uuid: 9.0.1 - transitivePeerDependencies: - - encoding - - supports-color - - gcp-metadata@6.1.1(encoding@0.1.13): - dependencies: - gaxios: 6.7.1(encoding@0.1.13) - google-logging-utils: 0.0.2 - json-bigint: 1.0.0 - transitivePeerDependencies: - - encoding - - supports-color - gensync@1.0.0-beta.2: {} geo-coordinates-parser@1.7.4: {} @@ -19475,8 +18549,6 @@ snapshots: globrex@0.1.2: {} - google-logging-utils@0.0.2: {} - gopd@1.2.0: {} got@12.6.1: @@ -20326,10 +19398,6 @@ snapshots: jsesc@3.1.0: {} - json-bigint@1.0.0: - dependencies: - bignumber.js: 9.3.1 - json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -21268,7 +20336,8 @@ snapshots: mimic-function@5.0.1: {} - mimic-response@2.1.0: {} + mimic-response@2.1.0: + optional: true mimic-response@3.1.0: {} @@ -21418,7 +20487,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nan@2.23.0: {} + nan@2.23.0: + optional: true nanoid@3.3.11: {} @@ -23156,11 +22226,6 @@ snapshots: dependencies: glob: 7.2.3 - rimraf@6.0.1: - dependencies: - glob: 11.0.3 - package-json-from-dist: 1.0.1 - robust-predicates@3.0.2: {} rollup-plugin-visualizer@6.0.3(rollup@4.46.3): @@ -23413,11 +22478,6 @@ snapshots: setprototypeof@1.2.0: {} - sha.js@2.4.11: - dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - shallow-clone@3.0.1: dependencies: kind-of: 6.0.3 @@ -23540,13 +22600,17 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: {} + simple-concat@1.0.1: + optional: true simple-get@3.1.1: dependencies: decompress-response: 4.2.1 once: 1.4.0 simple-concat: 1.0.1 + optional: true + + simple-icons@15.14.0: {} simple-swizzle@0.2.2: dependencies: @@ -23699,8 +22763,6 @@ snapshots: argparse: 2.0.1 nearley: 2.20.1 - sql-highlight@6.1.0: {} - srcset@4.0.0: {} ssh-remote-port-forward@1.0.4: @@ -24023,7 +23085,7 @@ snapshots: tailwind-merge@3.3.1: {} - tailwind-variants@2.1.0(tailwind-merge@3.3.1)(tailwindcss@4.1.12): + tailwind-variants@3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.12): dependencies: tailwindcss: 4.1.12 optionalDependencies: @@ -24362,30 +23424,6 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.25(ioredis@5.7.0)(pg@8.16.3)(reflect-metadata@0.2.2): - dependencies: - '@sqltools/formatter': 1.2.5 - ansis: 3.17.0 - app-root-path: 3.1.0 - buffer: 6.0.3 - dayjs: 1.11.13 - debug: 4.4.1 - dedent: 1.6.0 - dotenv: 16.6.1 - glob: 10.4.5 - reflect-metadata: 0.2.2 - sha.js: 2.4.11 - sql-highlight: 6.1.0 - tslib: 2.8.1 - uuid: 11.1.0 - yargs: 17.7.2 - optionalDependencies: - ioredis: 5.7.0 - pg: 8.16.3 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - typescript-eslint@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2): dependencies: '@typescript-eslint/eslint-plugin': 8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2) @@ -24773,9 +23811,9 @@ snapshots: optionalDependencies: vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): dependencies: @@ -24821,6 +23859,50 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@22.18.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + '@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.2.0 + debug: 4.4.1 + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.2(@types/node@22.18.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.18.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.18.1 + happy-dom: 18.0.1 + jsdom: 26.1.0(canvas@2.11.2) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 diff --git a/readme_i18n/README_uk_UA.md b/readme_i18n/README_uk_UA.md index 5a33fa210d..33687bbc50 100644 --- a/readme_i18n/README_uk_UA.md +++ b/readme_i18n/README_uk_UA.md @@ -42,11 +42,11 @@ - âš ī¸ ĐĻĐĩĐš ĐŋŅ€ĐžŅ”ĐēŅ‚ ĐŋĐĩŅ€ĐĩĐąŅƒĐ˛Đ°Ņ” **в Đ´ŅƒĐļĐĩ аĐēŅ‚Đ¸Đ˛ĐŊŅ–Đš** Ņ€ĐžĐˇŅ€ĐžĐąŅ†Ņ–. - âš ī¸ ĐžŅ‡Ņ–ĐēŅƒĐšŅ‚Đĩ ĐąĐĩСĐģҖ҇ ĐŋĐžĐŧиĐģĐžĐē Ņ– ĐŗĐģОйаĐģҌĐŊĐ¸Ņ… СĐŧŅ–ĐŊ. -- âš ī¸ **НĐĩ виĐēĐžŅ€Đ¸ŅŅ‚ĐžĐ˛ŅƒĐšŅ‚Đĩ ҆ĐĩĐš Đ´ĐžĐ´Đ°Ņ‚ĐžĐē ŅĐē Ņ”Đ´Đ¸ĐŊĐĩ ŅŅ…ĐžĐ˛Đ¸Ņ‰Đĩ ŅĐ˛ĐžŅ—Ņ… Ņ„ĐžŅ‚Đž Ņ‚Đ° Đ˛Ņ–Đ´ĐĩĐž.** +- âš ī¸ **НĐĩ виĐēĐžŅ€Đ¸ŅŅ‚ĐžĐ˛ŅƒĐšŅ‚Đĩ ҆ĐĩĐš ĐˇĐ°ŅŅ‚ĐžŅŅƒĐŊĐžĐē ŅĐē Ņ”Đ´Đ¸ĐŊĐĩ ŅŅ…ĐžĐ˛Đ¸Ņ‰Đĩ ŅĐ˛ĐžŅ—Ņ… Ņ„ĐžŅ‚Đž Ņ‚Đ° Đ˛Ņ–Đ´ĐĩĐž.** - âš ī¸ ЗавĐļди Đ´ĐžŅ‚Ņ€Đ¸ĐŧŅƒĐšŅ‚ĐĩҁҌ [ĐŋĐģаĐŊ҃ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊĐžĐŗĐž ĐēĐžĐŋŅ–ŅŽĐ˛Đ°ĐŊĐŊŅ 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Đ´ĐģŅ Đ˛Đ°ŅˆĐ¸Ņ… Đ´ĐžŅ€ĐžĐŗĐžŅ†Ņ–ĐŊĐŊĐ¸Ņ… Ņ„ĐžŅ‚ĐžĐŗŅ€Đ°Ņ„Ņ–Đš Ņ‚Đ° Đ˛Ņ–Đ´ĐĩĐž! > [!NOTE] -> ĐžŅĐŊОвĐŊ҃ Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đ°Ņ†Ņ–ŅŽ, СОĐēŅ€ĐĩĐŧа ĐŋĐžŅŅ–ĐąĐŊиĐēи С Đ˛ŅŅ‚Đ°ĐŊОвĐģĐĩĐŊĐŊŅ, ĐŧĐžĐļĐŊа СĐŊĐ°ĐšŅ‚Đ¸ Са Đ°Đ´Ņ€ĐĩŅĐžŅŽ https://immich.app/. +> ĐžŅĐŊОвĐŊ҃ Đ´ĐžĐē҃ĐŧĐĩĐŊŅ‚Đ°Ņ†Ņ–ŅŽ, СОĐēŅ€ĐĩĐŧа ĐŋĐžŅŅ–ĐąĐŊиĐēи ĐˇŅ– Đ˛ŅŅ‚Đ°ĐŊОвĐģĐĩĐŊĐŊŅ, ĐŧĐžĐļĐŊа СĐŊĐ°ĐšŅ‚Đ¸ Са Đ°Đ´Ņ€ĐĩŅĐžŅŽ https://immich.app/. ## ĐŸĐžŅĐ¸ĐģаĐŊĐŊŅ @@ -61,7 +61,7 @@ ## ДĐĩĐŧĐž -Đ”ĐžŅŅ‚ŅƒĐŋ Đ´Đž Đ´ĐĩĐŧĐž-вĐĩҀҁҖҗ [Ņ‚ŅƒŅ‚](https://demo.immich.app). ДĐģŅ ĐŧĐžĐąŅ–ĐģҌĐŊĐžĐŗĐž Đ´ĐžĐ´Đ°Ņ‚Đē҃ ви ĐŧĐžĐļĐĩŅ‚Đĩ виĐēĐžŅ€Đ¸ŅŅ‚ĐžĐ˛ŅƒĐ˛Đ°Ņ‚Đ¸ `https://demo.immich.app` в ŅĐēĐžŅŅ‚Ņ– `Server Endpoint URL`. +Đ”ĐžŅŅ‚ŅƒĐŋ Đ´Đž Đ´ĐĩĐŧĐž-вĐĩҀҁҖҗ [Ņ‚ŅƒŅ‚](https://demo.immich.app). ДĐģŅ ĐŧĐžĐąŅ–ĐģҌĐŊĐžĐŗĐž ĐˇĐ°ŅŅ‚ĐžŅŅƒĐŊĐē҃ ви ĐŧĐžĐļĐĩŅ‚Đĩ виĐēĐžŅ€Đ¸ŅŅ‚ĐžĐ˛ŅƒĐ˛Đ°Ņ‚Đ¸ `https://demo.immich.app` в ŅĐēĐžŅŅ‚Ņ– `Server Endpoint URL`. ### ОбĐģŅ–ĐēĐžĐ˛Ņ– даĐŊŅ– Đ´ĐģŅ Đ˛Ņ…ĐžĐ´Ņƒ @@ -74,7 +74,7 @@ | Đ¤ŅƒĐŊĐē҆Җҗ | Đ”ĐžĐ´Đ°Ņ‚ĐžĐē | ВĐĩĐą | | :------------------------------------------------------- | ------- | --- | | ЗаваĐŊŅ‚Đ°ĐļĐĩĐŊĐŊŅ Ņ‚Đ° ĐŋĐĩŅ€ĐĩĐŗĐģŅĐ´ Đ˛Ņ–Đ´ĐĩĐž Đš Ņ„ĐžŅ‚Đž | ĐĸаĐē | ĐĸаĐē | -| ĐĐ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐŊĐĩ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊĐĩ ĐēĐžĐŋŅ–ŅŽĐ˛Đ°ĐŊĐŊŅ ĐŋŅ€Đ¸ Đ˛Ņ–Đ´ĐēŅ€Đ¸Ņ‚Ņ‚Ņ– Đ´ĐžĐ´Đ°Ņ‚Đēа | ĐĸаĐē | Н/Д | +| ĐĐ˛Ņ‚ĐžĐŧĐ°Ņ‚Đ¸Ņ‡ĐŊĐĩ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊĐĩ ĐēĐžĐŋŅ–ŅŽĐ˛Đ°ĐŊĐŊŅ ĐŋŅ€Đ¸ Đ˛Ņ–Đ´ĐēŅ€Đ¸Ņ‚Ņ‚Ņ– ĐˇĐ°ŅŅ‚ĐžŅŅƒĐŊĐē҃ | ĐĸаĐē | Н/Д | | ЗаĐŋĐžĐąŅ–ĐŗĐ°ĐŊĐŊŅ Đ´ŅƒĐąĐģŅŽĐ˛Đ°ĐŊĐŊŅŽ Ņ„Đ°ĐšĐģŅ–Đ˛ | ĐĸаĐē | ĐĸаĐē | | Đ’Đ¸ĐąŅ–Ņ€ аĐģŅŒĐąĐžĐŧŅ–Đ˛ Đ´ĐģŅ Ņ€ĐĩСĐĩŅ€Đ˛ĐŊĐžĐŗĐž ĐēĐžĐŋŅ–ŅŽĐ˛Đ°ĐŊĐŊŅ | ĐĸаĐē | Н/Д | | ЗаваĐŊŅ‚Đ°ĐļĐĩĐŊĐŊŅ Ņ„ĐžŅ‚Đž Ņ‚Đ° Đ˛Ņ–Đ´ĐĩĐž ĐŊа ĐģĐžĐēаĐģҌĐŊиК ĐŋŅ€Đ¸ŅŅ‚Ņ€Ņ–Đš | ĐĸаĐē | ĐĸаĐē | @@ -112,7 +112,7 @@ ĐĄŅ‚Đ°Ņ‚ŅƒŅ ĐŋĐĩŅ€ĐĩĐēĐģĐ°Đ´Ņ–Đ˛ -## АĐēŅ‚Đ¸Đ˛ĐŊŅ–ŅŅ‚ŅŒ Ņ€ĐĩĐŋĐžĐˇĐ¸Ņ‚Đ°Ņ€Ņ–ŅŽ +## АĐēŅ‚Đ¸Đ˛ĐŊŅ–ŅŅ‚ŅŒ Ņ€ĐĩĐŋĐžĐˇĐ¸Ņ‚ĐžŅ€Ņ–ŅŽ ![Đ”Ņ–ŅĐģҌĐŊŅ–ŅŅ‚ŅŒ](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Đ—ĐžĐąŅ€Đ°ĐļĐĩĐŊĐŊŅ аĐŊаĐģŅ–Ņ‚Đ¸Đēи Repobeats") diff --git a/server/Dockerfile b/server/Dockerfile index a554e19406..6bdf57d4dc 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS dev +FROM ghcr.io/immich-app/base-server-dev:202509091104@sha256:4f9275330f1e49e7ce9840758ea91839052fe6ed40972d5bb97a9af857fa956a AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ @@ -77,7 +77,7 @@ RUN apt-get update \ RUN dart --disable-analytics # production-builder-base image -FROM ghcr.io/immich-app/base-server-dev:202509021104@sha256:47d38c94775332000a93fbbeca1c796687b2d2919e3c75b6e26ab8a65d1864f3 AS prod-builder-base +FROM ghcr.io/immich-app/base-server-dev:202509091104@sha256:4f9275330f1e49e7ce9840758ea91839052fe6ed40972d5bb97a9af857fa956a AS prod-builder-base ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp @@ -115,7 +115,7 @@ RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install && pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned # prod base image -FROM ghcr.io/immich-app/base-server-prod:202509021104@sha256:84f3727cff75c623f79236cdd9a2b72c84f7665057f474851016f702c67157af +FROM ghcr.io/immich-app/base-server-prod:202509091104@sha256:d1ccbac24c84f2f8277cf85281edfca62d85d7daed6a62b8efd3a81bcd3c5e0e WORKDIR /usr/src/app ENV NODE_ENV=production \ @@ -125,7 +125,7 @@ ENV NODE_ENV=production \ COPY --from=server-prod /output/server-pruned ./server COPY --from=web-prod /usr/src/app/web/build /build/www COPY --from=cli-prod /output/cli-pruned ./cli -RUN ln -s ./cli/bin/immich server/bin/immich +RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE diff --git a/server/bin/start.sh b/server/bin/start.sh index 10f897dd8e..15390ae158 100755 --- a/server/bin/start.sh +++ b/server/bin/start.sh @@ -8,7 +8,7 @@ else echo "skipping libmimalloc - path not found $lib_path" fi export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib" -SERVER_HOME=/usr/src/app/server +SERVER_HOME="$(readlink -f "$(dirname "$0")/..")" read_file_and_export() { fname="${!1}" diff --git a/server/package.json b/server/package.json index 554a65c0f3..c2a0c6c8cd 100644 --- a/server/package.json +++ b/server/package.json @@ -37,14 +37,12 @@ "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", - "@nestjs/event-emitter": "^3.0.0", "@nestjs/platform-express": "^11.0.4", "@nestjs/platform-socket.io": "^11.0.4", "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.0.2", "@nestjs/websockets": "^11.0.4", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/auto-instrumentations-node": "^0.62.0", "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/exporter-prometheus": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.203.0", @@ -108,20 +106,16 @@ "socket.io": "^4.8.1", "tailwindcss-preset-email": "^1.4.0", "thumbhash": "^0.1.1", - "typeorm": "^0.3.17", "ua-parser-js": "^2.0.0", "uuid": "^11.1.0", "validator": "^13.12.0" }, "devDependencies": { - "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", "@nestjs/cli": "^11.0.2", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.4", "@swc/core": "^1.4.14", - "@testcontainers/postgresql": "^11.0.0", - "@testcontainers/redis": "^11.0.0", "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^6.0.0", @@ -146,29 +140,23 @@ "@types/ua-parser-js": "^0.7.36", "@types/validator": "^13.15.2", "@vitest/coverage-v8": "^3.0.0", - "canvas": "^3.1.0", "eslint": "^9.14.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^60.0.0", "globals": "^16.0.0", "mock-fs": "^5.2.0", - "node-addon-api": "^8.3.1", "node-gyp": "^11.2.0", "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", - "rimraf": "^6.0.0", - "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", "supertest": "^7.1.0", "tailwindcss": "^3.4.0", "testcontainers": "^11.0.0", - "tsconfig-paths": "^4.2.0", "typescript": "^5.9.2", "typescript-eslint": "^8.28.0", "unplugin-swc": "^1.4.5", - "utimes": "^5.2.1", "vite-tsconfig-paths": "^5.0.0", "vitest": "^3.0.0" }, diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 171cfe7047..688e513b64 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -96,8 +96,9 @@ export class AssetMediaController { @Put(':id/original') @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') - @EndpointLifecycle({ addedAt: 'v1.106.0' }) - @ApiOperation({ + @EndpointLifecycle({ + addedAt: 'v1.106.0', + deprecatedAt: 'v1.142.0', summary: 'replaceAsset', description: 'Replace the asset with new file, without changing its id', }) diff --git a/server/src/decorators.ts b/server/src/decorators.ts index b88f2d2d7e..2f1e76d097 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,5 +1,5 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; +import { ApiExtension, ApiOperation, ApiOperationOptions, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; @@ -159,12 +159,21 @@ type LifecycleMetadata = { deprecatedAt?: LifecycleRelease; }; -export const EndpointLifecycle = ({ addedAt, deprecatedAt }: LifecycleMetadata) => { +export const EndpointLifecycle = ({ + addedAt, + deprecatedAt, + description, + ...options +}: LifecycleMetadata & ApiOperationOptions) => { const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })]; if (deprecatedAt) { decorators.push( ApiTags('Deprecated'), - ApiOperation({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt }), + ApiOperation({ + deprecated: true, + description: DEPRECATED_IN_PREFIX + deprecatedAt + (description ? `. ${description}` : ''), + ...options, + }), ); } diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 449cec3207..58772da00b 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -53,6 +53,12 @@ export class TimeBucketDto { description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)', }) visibility?: AssetVisibility; + + @ValidateBoolean({ + optional: true, + description: 'Include location data in the response', + }) + withCoordinates?: boolean; } export class TimeBucketAssetDto extends TimeBucketDto { @@ -185,6 +191,22 @@ export class TimeBucketAssetResponseDto { description: 'Array of country names extracted from EXIF GPS data', }) country!: (string | null)[]; + + @ApiProperty({ + type: 'array', + required: false, + items: { type: 'number', nullable: true }, + description: 'Array of latitude coordinates extracted from EXIF GPS data', + }) + latitude!: number[]; + + @ApiProperty({ + type: 'array', + required: false, + items: { type: 'number', nullable: true }, + description: 'Array of longitude coordinates extracted from EXIF GPS data', + }) + longitude!: number[]; } export class TimeBucketsResponseDto { diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b5a7b19dcd..49469d5491 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -60,6 +60,7 @@ interface AssetBuilderOptions { status?: AssetStatus; assetType?: AssetType; visibility?: AssetVisibility; + withCoordinates?: boolean; } export interface TimeBucketOptions extends AssetBuilderOptions { @@ -629,6 +630,7 @@ export class AssetRepository { ) .as('ratio'), ]) + .$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude'])) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility == undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) @@ -702,6 +704,12 @@ export class AssetRepository { eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'), eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'), ]) + .$if(!!options.withCoordinates, (qb) => + qb.select((eb) => [ + eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'), + eb.fn.coalesce(eb.fn('array_agg', ['longitude']), sql.lit('{}')).as('longitude'), + ]), + ) .$if(!!options.withStacked, (qb) => qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')), ), diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index c1b26d5dde..ec4c8a8f52 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -81,7 +81,7 @@ type EventMap = { StackDeleteAll: [{ stackIds: string[]; userId: string }]; // user events - UserSignup: [{ notify: boolean; id: string; tempPassword?: string }]; + UserSignup: [{ notify: boolean; id: string; password?: string }]; // websocket events WebsocketConnect: [{ userId: string }]; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 413b20a954..0adb390f6a 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1660,5 +1660,16 @@ describe(MetadataService.name, () => { expect(result?.tag).toBe('GPSDateTime'); expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z'); }); + + it('should prefer CreationDate over CreateDate', () => { + const tags = { + CreationDate: '2025:05:24 18:26:20+02:00', + CreateDate: '2025:08:27 08:45:40', + }; + + const result = firstDateTime(tags); + expect(result?.tag).toBe('CreationDate'); + expect(result?.dateTime?.toDate()?.toISOString()).toBe('2025-05-24T16:26:20.000Z'); + }); }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 94ccd41ff5..7d3de76550 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -39,9 +39,9 @@ const EXIF_DATE_TAGS: Array = [ 'SubSecCreateDate', 'SubSecMediaCreateDate', 'DateTimeOriginal', + 'CreationDate', 'CreateDate', 'MediaCreateDate', - 'CreationDate', 'DateTimeCreated', 'GPSDateTime', 'DateTimeUTC', diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index eef1c4f8b2..11c385b1e2 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -147,7 +147,7 @@ describe(NotificationService.name, () => { await sut.onUserSignup({ id: '', notify: true }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NotifyUserSignup, - data: { id: '', tempPassword: undefined }, + data: { id: '', password: undefined }, }); }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 1a257309b2..91a043d405 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -191,9 +191,9 @@ export class NotificationService extends BaseService { } @OnEvent({ name: 'UserSignup' }) - async onUserSignup({ notify, id, tempPassword }: ArgOf<'UserSignup'>) { + async onUserSignup({ notify, id, password: password }: ArgOf<'UserSignup'>) { if (notify) { - await this.jobRepository.queue({ name: JobName.NotifyUserSignup, data: { id, tempPassword } }); + await this.jobRepository.queue({ name: JobName.NotifyUserSignup, data: { id, password } }); } } @@ -251,70 +251,8 @@ export class NotificationService extends BaseService { return { messageId }; } - async getTemplate(name: EmailTemplate, customTemplate: string) { - const { server, templates } = await this.getConfig({ withCache: false }); - - let templateResponse = ''; - - switch (name) { - case EmailTemplate.WELCOME: { - const { html: _welcomeHtml } = await this.emailRepository.renderEmail({ - template: EmailTemplate.WELCOME, - data: { - baseUrl: getExternalDomain(server), - displayName: 'John Doe', - username: 'john@doe.com', - password: 'thisIsAPassword123', - }, - customTemplate: customTemplate || templates.email.welcomeTemplate, - }); - - templateResponse = _welcomeHtml; - break; - } - case EmailTemplate.ALBUM_UPDATE: { - const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({ - template: EmailTemplate.ALBUM_UPDATE, - data: { - baseUrl: getExternalDomain(server), - albumId: '1', - albumName: 'Favorite Photos', - recipientName: 'Jane Doe', - cid: undefined, - }, - customTemplate: customTemplate || templates.email.albumInviteTemplate, - }); - templateResponse = _updateAlbumHtml; - break; - } - - case EmailTemplate.ALBUM_INVITE: { - const { html } = await this.emailRepository.renderEmail({ - template: EmailTemplate.ALBUM_INVITE, - data: { - baseUrl: getExternalDomain(server), - albumId: '1', - albumName: "John Doe's Favorites", - senderName: 'John Doe', - recipientName: 'Jane Doe', - cid: undefined, - }, - customTemplate: customTemplate || templates.email.albumInviteTemplate, - }); - templateResponse = html; - break; - } - default: { - templateResponse = ''; - break; - } - } - - return { name, html: templateResponse }; - } - @OnJob({ name: JobName.NotifyUserSignup, queue: QueueName.Notification }) - async handleUserSignup({ id, tempPassword }: JobOf) { + async handleUserSignup({ id, password }: JobOf) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { return JobStatus.Skipped; @@ -327,7 +265,7 @@ export class NotificationService extends BaseService { baseUrl: getExternalDomain(server), displayName: user.name, username: user.email, - password: tempPassword, + password, }, customTemplate: templates.email.welcomeTemplate, }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 3ae9d429eb..ce70419ff6 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -38,7 +38,7 @@ export class UserAdminService extends BaseService { await this.eventRepository.emit('UserSignup', { notify: !!notify, id: user.id, - tempPassword: user.shouldChangePassword ? userDto.password : undefined, + password: userDto.password, }); return mapUserAdmin(user); diff --git a/server/src/types.ts b/server/src/types.ts index 18dec00328..4de768d8c9 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -249,7 +249,7 @@ export interface IEmailJob { } export interface INotifySignupJob extends IEntityJob { - tempPassword?: string; + password?: string; } export interface INotifyAlbumInviteJob extends IEntityJob { diff --git a/web/eslint.config.js b/web/eslint.config.js index e15f80e8e0..54337ea78f 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -1,5 +1,6 @@ import js from '@eslint/js'; import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat'; +import prettier from 'eslint-config-prettier'; import eslintPluginCompat from 'eslint-plugin-compat'; import eslintPluginSvelte from 'eslint-plugin-svelte'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; @@ -17,6 +18,7 @@ export default typescriptEslint.config( ...eslintPluginSvelte.configs.recommended, eslintPluginUnicorn.configs.recommended, js.configs.recommended, + prettier, { plugins: { tscompat: tslintPluginCompat, diff --git a/web/package.json b/web/package.json index 58009ec8a1..86574fde4f 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.24.0", + "@immich/ui": "^0.27.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -62,7 +62,6 @@ "thumbhash": "^0.1.1" }, "devDependencies": { - "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.18.0", "@faker-js/faker": "^9.3.0", "@koddsson/eslint-plugin-tscompat": "^0.2.0", @@ -82,7 +81,6 @@ "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", "@vitest/coverage-v8": "^3.0.0", - "autoprefixer": "^10.4.17", "dotenv": "^17.0.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.1.8", @@ -102,7 +100,6 @@ "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.2.0", "tailwindcss": "^4.1.7", - "tslib": "^2.6.2", "typescript": "^5.8.3", "typescript-eslint": "^8.28.0", "vite": "^7.1.2", diff --git a/web/src/app.css b/web/src/app.css index db6c43652b..f66743f736 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -169,3 +169,13 @@ filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); } } + +.maplibregl-popup { + .maplibregl-popup-tip { + @apply border-t-subtle! translate-y-[-1px]; + } + + .maplibregl-popup-content { + @apply bg-subtle rounded-lg; + } +} diff --git a/web/src/lib/components/album-page/album-map.svelte b/web/src/lib/components/album-page/album-map.svelte index 7d7060ac0a..c161bac552 100644 --- a/web/src/lib/components/album-page/album-map.svelte +++ b/web/src/lib/components/album-page/album-map.svelte @@ -1,4 +1,5 @@ - -
-
-
- - -
- -
- - -
- -
- - -
- -
- -
-
-
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index d369068a2c..e9282ae5d8 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -42,6 +42,7 @@ onReload?: (() => void) | undefined; pageHeaderOffset?: number; slidingWindowOffset?: number; + arrowNavigation?: boolean; } let { @@ -60,6 +61,7 @@ onReload = undefined, slidingWindowOffset = 0, pageHeaderOffset = 0, + arrowNavigation = true, }: Props = $props(); let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore; @@ -306,8 +308,12 @@ { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() }, - { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset }, - { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset }, + ...(arrowNavigation + ? [ + { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset }, + { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset }, + ] + : []), ]; if (assetInteraction.selectionActive) { diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index ccc0249043..70e0aab076 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -2,11 +2,12 @@ import { shortcuts } from '$lib/actions/shortcut'; import Portal from '$lib/components/shared-components/portal/portal.svelte'; import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; - import { type AssetResponseDto } from '@immich/sdk'; + import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; @@ -42,32 +43,32 @@ assetViewingStore.showAssetViewer(false); }); - const onNext = () => { + const onNext = async () => { const index = getAssetIndex($viewingAsset.id) + 1; if (index >= assets.length) { - return Promise.resolve(false); + return false; } - setAsset(assets[index]); - return Promise.resolve(true); + await onViewAsset(assets[index]); + return true; }; - const onPrevious = () => { + const onPrevious = async () => { const index = getAssetIndex($viewingAsset.id) - 1; if (index < 0) { - return Promise.resolve(false); + return false; } - setAsset(assets[index]); - return Promise.resolve(true); + await onViewAsset(assets[index]); + return true; }; - const onRandom = () => { + const onRandom = async () => { if (assets.length <= 0) { - return Promise.resolve(undefined); + return; } const index = Math.floor(Math.random() * assets.length); const asset = assets[index]; - setAsset(asset); - return Promise.resolve(asset); + await onViewAsset(asset); + return { id: asset.id }; }; const onSelectAsset = (asset: AssetResponseDto) => { @@ -86,6 +87,12 @@ selectedAssetIds = new SvelteSet(assets.map((asset) => asset.id)); }; + const onViewAsset = async ({ id }: AssetResponseDto) => { + const asset = await getAssetInfo({ ...authManager.params, id }); + setAsset(asset); + await navigate({ targetRoute: 'current', assetId: asset.id }); + }; + const handleResolve = () => { const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id)); const duplicateAssetIds = assets.map((asset) => asset.id); @@ -102,9 +109,7 @@ { shortcut: { key: 'a' }, onShortcut: onSelectAll }, { shortcut: { key: 's' }, - onShortcut: () => { - setAsset(assets[0]); - }, + onShortcut: () => onViewAsset(assets[0]), }, { shortcut: { key: 'd' }, onShortcut: onSelectNone }, { shortcut: { key: 'c', shift: true }, onShortcut: handleResolve }, @@ -166,12 +171,7 @@
{#each assets as asset (asset.id)} - setAsset(asset)} - /> + {/each}
diff --git a/web/src/lib/components/utilities-page/geolocation/geolocation.svelte b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte deleted file mode 100644 index 0efde6df7e..0000000000 --- a/web/src/lib/components/utilities-page/geolocation/geolocation.svelte +++ /dev/null @@ -1,104 +0,0 @@ - - -
-
- { - if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) { - onLocation({ latitude: asset.exifInfo?.latitude, longitude: asset.exifInfo?.longitude }); - } else { - onSelectAsset(asset); - } - }} - onSelect={() => onSelectAsset(asset)} - onMouseEvent={() => onMouseEvent(asset)} - selected={assetInteraction.hasSelectedAsset(asset.id)} - selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} - thumbnailSize={boxWidth} - readonly={hasGps} - /> - - {#if hasGps} -
- {$t('gps')} -
- {:else} -
- {$t('gps_missing')} -
- {/if} -
- -
-

- {new Date(asset.localDateTime).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - })} -

-

- {new Date(asset.localDateTime).toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZone: 'UTC', - })} -

- {#if hasGps} -

- {asset.exifInfo?.country} -

-

- {asset.exifInfo?.city} -

- {/if} -
-
diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 03d138f680..e406972900 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -187,6 +187,11 @@ export class MonthGroup { thumbhash: bucketAssets.thumbhash[i], people: null, // People are not included in the bucket assets }; + + if (bucketAssets.latitude?.[i] && bucketAssets.longitude?.[i]) { + timelineAsset.latitude = bucketAssets.latitude?.[i]; + timelineAsset.longitude = bucketAssets.longitude?.[i]; + } this.addTimelineAsset(timelineAsset, addContext); } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 18ee0426f3..fea62084b2 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -31,6 +31,8 @@ export type TimelineAsset = { city: string | null; country: string | null; people: string[] | null; + latitude?: number | null; + longitude?: number | null; }; export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; diff --git a/web/src/lib/managers/upload-manager.svelte.ts b/web/src/lib/managers/upload-manager.svelte.ts index 0ff2b0c214..61c6d73b53 100644 --- a/web/src/lib/managers/upload-manager.svelte.ts +++ b/web/src/lib/managers/upload-manager.svelte.ts @@ -1,11 +1,16 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; +import { uploadAssetsStore } from '$lib/stores/upload'; import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk'; class UploadManager { mediaTypes = $state({ image: [], sidecar: [], video: [] }); constructor() { - eventManager.on('app.init', () => void this.#loadExtensions()); + eventManager.on('app.init', () => void this.#loadExtensions()).on('auth.logout', () => void this.reset()); + } + + reset() { + uploadAssetsStore.reset(); } async #loadExtensions() { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 822820911e..1e6295242a 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -34,6 +34,7 @@ import { type AssetResponseDto, type AssetTypeEnum, type DownloadInfoDto, + type ExifResponseDto, type StackResponseDto, type UserPreferencesResponseDto, type UserResponseDto, @@ -328,6 +329,15 @@ export function isFlipped(orientation?: string | null) { return value && (isRotated270CW(value) || isRotated90CW(value)); } +export const getDimensions = (exifInfo: ExifResponseDto) => { + const { exifImageWidth: width, exifImageHeight: height } = exifInfo; + if (isFlipped(exifInfo.orientation)) { + return { width: height, height: width }; + } + + return { width, height }; +}; + export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string { const size = asset.exifInfo?.fileSizeInByte || 0; return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data'; diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index bca57863a9..d96bef45d6 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { buildDateRangeFromYearMonthAndDay, getAlbumDateRange, timeToSeconds } from './date-time'; +import { getAlbumDateRange, timeToSeconds } from './date-time'; describe('converting time to seconds', () => { it('parses hh:mm:ss correctly', () => { @@ -75,24 +75,3 @@ describe('getAlbumDate', () => { expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); }); }); - -describe('buildDateRangeFromYearMonthAndDay', () => { - it('should build correct date range for a specific day', () => { - const result = buildDateRangeFromYearMonthAndDay(2023, 1, 8); - - expect(result.from).toContain('2023-01-08T00:00:00'); - expect(result.to).toContain('2023-01-09T00:00:00'); - }); - - it('should build correct date range for a month', () => { - const result = buildDateRangeFromYearMonthAndDay(2023, 2); - expect(result.from).toContain('2023-02-01T00:00:00'); - expect(result.to).toContain('2023-03-01T00:00:00'); - }); - - it('should build correct date range for a year', () => { - const result = buildDateRangeFromYearMonthAndDay(2023); - expect(result.from).toContain('2023-01-01T00:00:00'); - expect(result.to).toContain('2024-01-01T00:00:00'); - }); -}); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index bf87d041cc..8a50df9cfe 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -85,33 +85,3 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string */ export const asLocalTimeISO = (date: DateTime) => (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); - -/** - * Creates a date range for filtering assets based on year, month, and day parameters - */ -export const buildDateRangeFromYearMonthAndDay = (year: number, month?: number, day?: number) => { - const baseDate = DateTime.fromObject({ - year, - month: month || 1, - day: day || 1, - }); - - let from: DateTime; - let to: DateTime; - - if (day) { - from = baseDate.startOf('day'); - to = baseDate.plus({ days: 1 }).startOf('day'); - } else if (month) { - from = baseDate.startOf('month'); - to = baseDate.plus({ months: 1 }).startOf('month'); - } else { - from = baseDate.startOf('year'); - to = baseDate.plus({ years: 1 }).startOf('year'); - } - - return { - from: from.toISO() || undefined, - to: to.toISO() || undefined, - }; -}; diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 5f519f9d8e..c572ec1760 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -2,6 +2,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { uploadManager } from '$lib/managers/upload-manager.svelte'; import { UploadState } from '$lib/models/upload-asset'; import { uploadAssetsStore } from '$lib/stores/upload'; +import { user } from '$lib/stores/user.store'; import { uploadRequest } from '$lib/utils'; import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; @@ -231,6 +232,11 @@ async function fileUploader({ return responseData.id; } catch (error) { + // ignore errors if the user logs out during uploads + if (!get(user)) { + return; + } + const errorMessage = handleError(error, $t('errors.unable_to_upload_file')); uploadAssetsStore.track('error'); uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: errorMessage }); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index a1147b708f..6a0f12c20e 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -190,6 +190,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): city: city || null, country: country || null, people, + latitude: assetResponse.exifInfo?.latitude || null, + longitude: assetResponse.exifInfo?.longitude || null, }; }; diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte index 48e94d750f..ab0a59f3ef 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.svelte +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -1,26 +1,21 @@ @@ -224,9 +139,7 @@ {#snippet buttons()}
- {#if filteredAssets.length > 0} - - {/if} +
-
- {#if isLoading}
{/if} - {#if filteredAssets && filteredAssets.length > 0} -
- {#each filteredAssets as asset (asset.id)} - handleSelectAssets(asset)} - onMouseEvent={(asset) => assetMouseEventHandler(asset)} - onLocation={(selected) => { - location = selected; - locationUpdated = true; - setTimeout(() => { - locationUpdated = false; - }, 1000); - }} - /> - {/each} -
- {:else} -
- {#if partialDate == null} - - {:else if showOnlyAssetsWithoutLocation && filteredAssets.length === 0 && assets.length > 0} - + + {#snippet customLayout(asset: TimelineAsset)} + {#if hasGps(asset)} +
+ {asset.city || $t('gps')} +
{:else} - +
+ {$t('gps_missing')} +
{/if} -
- {/if} + {/snippet} + {#snippet empty()} + {}} /> + {/snippet} +
diff --git a/web/src/routes/(user)/utilities/geolocation/+page.ts b/web/src/routes/(user)/utilities/geolocation/+page.ts index f5c227a7ef..1ada22a237 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.ts +++ b/web/src/routes/(user)/utilities/geolocation/+page.ts @@ -1,15 +1,12 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getQueryValue } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url); - const partialDate = getQueryValue('date'); const $t = await getFormatter(); return { - partialDate, meta: { title: $t('manage_geolocation'), }, diff --git a/web/src/routes/(user)/utilities/geolocation/photos/[photoId]/+page.ts b/web/src/routes/(user)/utilities/geolocation/photos/[photoId]/+page.ts new file mode 100644 index 0000000000..17fd84097f --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/photos/[photoId]/+page.ts @@ -0,0 +1,8 @@ +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (({ params }) => { + const photoId = params.photoId; + return redirect(302, `${AppRoute.PHOTOS}/${photoId}`); +}) satisfies PageLoad; diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index d1bd4e41d0..db846557e9 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -4,9 +4,16 @@ import { AppRoute } from '$lib/constants'; import JobCreateModal from '$lib/modals/JobCreateModal.svelte'; import { asyncTimeout } from '$lib/utils'; - import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk'; + import { handleError } from '$lib/utils/handle-error'; + import { + getAllJobsStatus, + JobCommand, + sendJobCommand, + type AllJobStatusResponseDto, + type JobName, + } from '@immich/sdk'; import { Button, HStack, modalManager, Text } from '@immich/ui'; - import { mdiCog, mdiPlus } from '@mdi/js'; + import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -21,6 +28,24 @@ let running = true; + const pausedJobs = $derived( + Object.entries(jobs ?? {}) + .filter(([_, jobStatus]) => jobStatus.queueStatus?.isPaused) + .map(([jobName]) => jobName as JobName), + ); + + const handleResumePausedJobs = async () => { + try { + for (const jobName of pausedJobs) { + await sendJobCommand({ id: jobName, jobCommandDto: { command: JobCommand.Resume, force: false } }); + } + // Refresh jobs status immediately after resuming + jobs = await getAllJobsStatus(); + } catch (error) { + handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } })); + } + }; + onMount(async () => { while (running) { jobs = await getAllJobsStatus(); @@ -36,6 +61,19 @@ {#snippet buttons()} + {#if pausedJobs.length > 0} + + {/if}