Merge branch 'main' into ocr

# Conflicts:
#	machine-learning/uv.lock
#	mobile/openapi/README.md
This commit is contained in:
CoderKang 2025-09-11 17:08:42 +08:00
commit 8840a4ec5f
108 changed files with 1983 additions and 2714 deletions

View file

@ -35,22 +35,21 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: yshavit/mdq:0.8.0@sha256:c69224d34224a0043d9a3ee46679ba4a2a25afaac445f293d92afe13cd47fcea image: yshavit/mdq:0.9.0@sha256:4399483ca857fb1a7ed28a596f754c7373e358647de31ce14b79a27c91e1e35e
outputs: outputs:
json: ${{ steps.get_checkbox.outputs.json }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:
- id: get_checkbox - id: get_checkbox
env: env:
BODY: ${{ needs.get_body.outputs.body }} 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: | run: |
JSON=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes') CHECKED=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes' | jq '.items[0].list[0].checked // false')
echo "json=$JSON" >> $GITHUB_OUTPUT echo "checked=$CHECKED" >> $GITHUB_OUTPUT
close_and_comment: close_and_comment:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [get_checkbox_json, should_run] 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: permissions:
issues: write issues: write
discussions: write discussions: write

View file

@ -50,7 +50,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - 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. # 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 # 📚 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 # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9 uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View file

@ -20,7 +20,7 @@ jobs:
run: echo 'The triggering workflow did not succeed' && exit 1 run: echo 'The triggering workflow did not succeed' && exit 1
- name: Get artifact - name: Get artifact
id: get-artifact id: get-artifact
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with: with:
script: | script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
@ -38,7 +38,7 @@ jobs:
return { found: true, id: matchArtifact.id }; return { found: true, id: matchArtifact.id };
- name: Determine deploy parameters - name: Determine deploy parameters
id: parameters id: parameters
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env: env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }} HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with: with:
@ -114,7 +114,7 @@ jobs:
- name: Load parameters - name: Load parameters
id: parameters id: parameters
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env: env:
PARAM_JSON: ${{ needs.checks.outputs.parameters }} PARAM_JSON: ${{ needs.checks.outputs.parameters }}
with: with:
@ -125,7 +125,7 @@ jobs:
core.setOutput("shouldDeploy", parameters.shouldDeploy); core.setOutput("shouldDeploy", parameters.shouldDeploy);
- name: Download artifact - name: Download artifact
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env: env:
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }} ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
with: with:

View file

@ -45,7 +45,7 @@ jobs:
message: 'chore: fix formatting' message: 'chore: fix formatting'
- name: Remove label - name: Remove label
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
if: always() if: always()
with: with:
script: | script: |

View file

@ -23,21 +23,24 @@ jobs:
run: | run: |
set -euo pipefail 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"
echo "$PR" | jq ' PR_NUMBER=$(echo "$PR" | jq '
if length == 1 then if length == 1 then
.[0].number .[0].number
else else
error("Expected exactly 1 entry, got \(length)") error("Expected exactly 1 entry, got \(length)")
end end
' 2>&1 | read PR_NUMBER || exit 1 ' 2>&1) || exit 1
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "Selected PR $PR_NUMBER" 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 - name: Generate a token
id: generate_token id: generate_token
@ -50,22 +53,25 @@ jobs:
env: env:
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
run: | 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 - name: Commit translations
env: env:
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
run: | run: |
curl -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=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=push
- name: Merge PR - name: Merge PR
id: merge_pr
env: env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }} GH_TOKEN: ${{ steps.generate_token.outputs.token }}
PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }} PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }}
run: | run: |
gh api -X POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" --field event='APPROVE' --field body='Automatically merging translations PR' \ set -euo pipefail
| jq '.id' | read REVIEW_ID
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 echo "REVIEW_ID=$REVIEW_ID" >> $GITHUB_OUTPUT
gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash
@ -75,8 +81,11 @@ jobs:
PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }} PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }}
REVIEW_ID: ${{ steps.merge_pr.outputs.REVIEW_ID }} REVIEW_ID: ${{ steps.merge_pr.outputs.REVIEW_ID }}
run: | run: |
# So we clean up no matter what
set +e
for i in {1..10}; do 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" echo "PR merged"
exit 0 exit 0
else else
@ -93,4 +102,4 @@ jobs:
env: env:
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
run: | 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

View file

@ -24,7 +24,7 @@ jobs:
permissions: permissions:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with: with:
script: | script: |
github.rest.issues.removeLabel({ github.rest.issues.removeLabel({

View file

@ -129,7 +129,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file - 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: with:
sarif_file: results.sarif sarif_file: results.sarif
category: zizmor category: zizmor

View file

@ -594,7 +594,7 @@ jobs:
contents: read contents: read
services: services:
postgres: 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: env:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ mobile/ios/fastlane/report.xml
vite.config.js.timestamp-* vite.config.js.timestamp-*
.pnpm-store .pnpm-store
.devcontainer/library
.devcontainer/.env*

View file

@ -13,7 +13,6 @@
"cli" "cli"
], ],
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",

View file

@ -143,13 +143,13 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
database: database:
container_name: immich_postgres 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_file:
- .env - .env
environment: environment:

View file

@ -56,14 +56,14 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
database: database:
container_name: immich_postgres 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_file:
- .env - .env
environment: environment:

View file

@ -49,14 +49,14 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280 image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
database: database:
container_name: immich_postgres 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: environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME} POSTGRES_USER: ${DB_USERNAME}

View file

@ -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. 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 :::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. 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). To configure these ports see [`IMMICH_API_METRICS_PORT` & `IMMICH_MICROSERVICES_METRICS_PORT`](/docs/install/environment-variables/#general).
::: :::

View file

@ -147,7 +147,10 @@ SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config';
### File properties ### File properties
```sql title="Without thumbnails" ```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" ```sql title="Failed file movements"

View file

@ -24,8 +24,6 @@
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0", "@mdx-js/react": "^3.0.0",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"classnames": "^2.3.2",
"clsx": "^2.0.0",
"docusaurus-lunr-search": "^3.3.2", "docusaurus-lunr-search": "^3.3.2",
"docusaurus-preset-openapi": "^0.7.5", "docusaurus-preset-openapi": "^0.7.5",
"lunr": "^2.3.9", "lunr": "^2.3.9",

View file

@ -105,6 +105,11 @@ const projects: CommunityProjectProps[] = [
description: 'Speed up your machine learning by load balancing your requests to multiple computers', description: 'Speed up your machine learning by load balancing your requests to multiple computers',
url: 'https://github.com/apetersson/immich_ml_balancer', 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 { function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {

View file

@ -19,7 +19,6 @@
"author": "", "author": "",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@immich/cli": "file:../cli", "@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
@ -31,7 +30,6 @@
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",

View file

@ -1466,10 +1466,10 @@ describe('/asset', () => {
expectedDate: '2023-04-04T04:00:00.000Z', expectedDate: '2023-04-04T04:00:00.000Z',
}, },
{ {
name: 'CreateDate when DateTimeOriginal missing', name: 'CreationDate when DateTimeOriginal missing',
exifData: { exifData: {
CreateDate: '2023:05:05 05:00:00', // TESTABLE CreationDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE CreateDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
}, },
expectedDate: '2023-05-05T05:00:00.000Z', expectedDate: '2023-05-05T05:00:00.000Z',

View file

@ -610,8 +610,6 @@
"backup_setting_subtitle": "Manage background and foreground upload settings", "backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_settings_subtitle": "Manage upload settings", "backup_settings_subtitle": "Manage upload settings",
"backward": "Backward", "backward": "Backward",
"beta_sync": "Beta Sync Status",
"beta_sync_subtitle": "Manage the new sync system",
"biometric_auth_enabled": "Biometric authentication enabled", "biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication", "biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available", "biometric_no_options": "No biometric options available",
@ -1089,10 +1087,7 @@
"gcast_enabled": "Google Cast", "gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.", "gcast_enabled_description": "This feature loads external resources from Google in order to work.",
"general": "General", "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_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_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", "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", "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_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_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_github": "GitHub", "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_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_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}", "profile_image_of_user": "Profile image of {user}",
@ -1659,6 +1654,7 @@
"restore_user": "Restore user", "restore_user": "Restore user",
"restored_asset": "Restored asset", "restored_asset": "Restored asset",
"resume": "Resume", "resume": "Resume",
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
"retry_upload": "Retry upload", "retry_upload": "Retry upload",
"review_duplicates": "Review duplicates", "review_duplicates": "Review duplicates",
"review_large_files": "Review large files", "review_large_files": "Review large files",
@ -1866,10 +1862,8 @@
"shift_to_permanent_delete": "press ⇧ to permanently delete asset", "shift_to_permanent_delete": "press ⇧ to permanently delete asset",
"show_album_options": "Show album options", "show_album_options": "Show album options",
"show_albums": "Show albums", "show_albums": "Show albums",
"show_all_assets": "Show all assets",
"show_all_people": "Show all people", "show_all_people": "Show all people",
"show_and_hide_people": "Show & hide people", "show_and_hide_people": "Show & hide people",
"show_assets_without_location": "Show assets without location",
"show_file_location": "Show file location", "show_file_location": "Show file location",
"show_gallery": "Show gallery", "show_gallery": "Show gallery",
"show_hidden_people": "Show hidden people", "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_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync_local": "Sync Local", "sync_local": "Sync Local",
"sync_remote": "Sync Remote", "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", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
"tag": "Tag", "tag": "Tag",
"tag_assets": "Tag assets", "tag_assets": "Tag assets",
@ -1999,6 +1995,7 @@
"trash_page_select_assets_btn": "Select assets", "trash_page_select_assets_btn": "Select assets",
"trash_page_title": "Trash ({count})", "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}}.", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"troubleshoot": "Troubleshoot",
"type": "Type", "type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code", "unable_to_change_pin_code": "Unable to change PIN code",
"unable_to_setup_pin_code": "Unable to setup PIN code", "unable_to_setup_pin_code": "Unable to setup PIN code",
@ -2054,7 +2051,6 @@
"use_biometric": "Use biometric", "use_biometric": "Use biometric",
"use_current_connection": "use current connection", "use_current_connection": "use current connection",
"use_custom_date_range": "Use custom date range instead", "use_custom_date_range": "Use custom date range instead",
"use_this_location": "Click to use location",
"user": "User", "user": "User",
"user_has_been_deleted": "This user has been deleted.", "user_has_been_deleted": "This user has been deleted.",
"user_id": "User ID", "user_id": "User ID",

View file

@ -1,6 +1,6 @@
ARG DEVICE=cpu 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 FROM builder-cpu AS builder-openvino
@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \ uv pip install /opt/onnxruntime_rocm-*.whl; \
fi 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 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 && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

View file

@ -76,7 +76,8 @@ enum StoreKey<T> {
betaTimeline<bool>._(1002), betaTimeline<bool>._(1002),
enableBackup<bool>._(1003), enableBackup<bool>._(1003),
useWifiForUploadVideos<bool>._(1004), useWifiForUploadVideos<bool>._(1004),
useWifiForUploadPhotos<bool>._(1005); useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006);
const StoreKey._(this.id); const StoreKey._(this.id);
final int id; final int id;

View file

@ -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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.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); return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
} }
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
return _localAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAsset(String id) { Future<RemoteAsset?> getRemoteAsset(String id) {
return _remoteAssetRepository.get(id); return _remoteAssetRepository.get(id);
} }
@ -89,4 +98,8 @@ class AssetService {
Future<int> getLocalHashedCount() { Future<int> getLocalHashedCount() {
return _localAssetRepository.getHashedCount(); return _localAssetRepository.getHashedCount();
} }
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
} }

View file

@ -5,7 +5,6 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.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/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/platform/background_worker_api.g.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:immich_mobile/utils/http_ssl_options.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
class BackgroundWorkerFgService { class BackgroundWorkerFgService {
final BackgroundWorkerFgHostApi _foregroundHostApi; final BackgroundWorkerFgHostApi _foregroundHostApi;
@ -42,8 +42,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift; final Drift _drift;
final DriftLogger _driftLogger; final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi; final BackgroundWorkerBgHostApi _backgroundHostApi;
final Logger _logger = Logger('BackgroundUploadBgService'); final Logger _logger = Logger('BackgroundWorkerBgService');
late final IsolateLockManager _lockManager;
bool _isCleanedUp = false; bool _isCleanedUp = false;
@ -59,7 +58,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
driftProvider.overrideWith(driftOverride(drift)), driftProvider.overrideWith(driftOverride(drift)),
], ],
); );
_lockManager = IsolateLockManager(onCloseRequest: _cleanup);
BackgroundWorkerFlutterApi.setUp(this); BackgroundWorkerFlutterApi.setUp(this);
} }
@ -67,41 +65,30 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async { Future<void> init() async {
try { try {
await loadTranslations();
HttpSSLOptions.apply(applyNative: false); HttpSSLOptions.apply(applyNative: false);
await _ref.read(authServiceProvider).setOpenApiServiceEndpoint();
// Initialize the file downloader await Future.wait([
await FileDownloader().configure( loadTranslations(),
globalConfig: [ workerManager.init(dynamicSpawning: true),
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 _ref.read(authServiceProvider).setOpenApiServiceEndpoint(),
(Config.holdingQueue, (6, 6, 3)), // Initialize the file downloader
// On Android, if files are larger than 256MB, run in foreground service FileDownloader().configure(
(Config.runInForegroundIfFileLargerThan, 256), globalConfig: [
], // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
); (Config.holdingQueue, (6, 6, 3)),
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); // On Android, if files are larger than 256MB, run in foreground service
await FileDownloader().trackTasks(); (Config.runInForegroundIfFileLargerThan, 256),
],
),
FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false),
FileDownloader().trackTasks(),
_ref.read(fileMediaRepositoryProvider).enableBackgroundAccess(),
]);
configureFileDownloaderNotifications(); configureFileDownloaderNotifications();
await _ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
// Notify the host that the background upload service has been initialized and is ready to use // Notify the host that the background worker service has been initialized and is ready to use
debugPrint("Acquiring background worker lock"); _backgroundHostApi.onInitialized();
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();
} catch (error, stack) { } catch (error, stack) {
_logger.severe("Failed to initialize background worker", error, stack); _logger.severe("Failed to initialize background worker", error, stack);
_backgroundHostApi.close(); _backgroundHostApi.close();
@ -170,6 +157,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_isCleanedUp = true; _isCleanedUp = true;
_logger.info("Cleaning up background worker"); _logger.info("Cleaning up background worker");
final cleanupFutures = [ final cleanupFutures = [
workerManager.dispose(),
_drift.close(), _drift.close(),
_driftLogger.close(), _driftLogger.close(),
_ref.read(backgroundSyncProvider).cancel(), _ref.read(backgroundSyncProvider).cancel(),
@ -180,8 +168,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
cleanupFutures.add(_isar.close()); cleanupFutures.add(_isar.close());
} }
_ref.dispose(); _ref.dispose();
_lockManager.releaseLock();
await Future.wait(cleanupFutures); await Future.wait(cleanupFutures);
_logger.info("Background worker resources cleaned up"); _logger.info("Background worker resources cleaned up");
} catch (error, stack) { } catch (error, stack) {
@ -190,52 +176,56 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
} }
Future<void> _handleBackup({bool processBulk = true}) async { Future<void> _handleBackup({bool processBulk = true}) async {
if (!_isBackupEnabled) { if (!_isBackupEnabled || _isCleanedUp) {
_logger.info("[_handleBackup 1] Backup is disabled. Skipping backup routine");
return; return;
} }
_logger.info("[_handleBackup 2] Enqueuing assets for backup from the background service");
final currentUser = _ref.read(currentUserProvider); final currentUser = _ref.read(currentUserProvider);
if (currentUser == null) { if (currentUser == null) {
_logger.warning("[_handleBackup 3] No current user found. Skipping backup from background");
return; return;
} }
if (processBulk) { if (processBulk) {
_logger.info("[_handleBackup 4] Resume backup from background");
return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
} }
final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id); final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id);
if (activeTask.isNotEmpty) { if (activeTask.isNotEmpty) {
_logger.info("[_handleBackup 5] Resuming backup for active tasks from background");
await _ref.read(uploadServiceProvider).resumeBackup(); await _ref.read(uploadServiceProvider).resumeBackup();
} else { } else {
_logger.info("[_handleBackup 6] Starting serial backup for new tasks from background");
await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id); await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id);
} }
} }
Future<void> _syncAssets({Duration? hashTimeout}) async { Future<void> _syncAssets({Duration? hashTimeout}) async {
final futures = <Future<void>>[]; await _ref.read(backgroundSyncProvider).syncLocal();
if (_isCleanedUp) {
return;
}
final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async { await _ref.read(backgroundSyncProvider).syncRemote();
if (_isCleanedUp) { if (_isCleanedUp) {
return; return;
} }
var hashFuture = _ref.read(backgroundSyncProvider).hashAssets(); var hashFuture = _ref.read(backgroundSyncProvider).hashAssets();
if (hashTimeout != null) { if (hashTimeout != null) {
hashFuture = hashFuture.timeout( hashFuture = hashFuture.timeout(
hashTimeout, hashTimeout,
onTimeout: () { onTimeout: () {
// Consume cancellation errors as we want to continue processing // Consume cancellation errors as we want to continue processing
}, },
); );
} }
return hashFuture; await hashFuture;
});
futures.add(localSyncFuture);
futures.add(_ref.read(backgroundSyncProvider).syncRemote());
await Future.wait(futures);
} }
} }

View file

@ -90,7 +90,7 @@ class StoreService {
_cache.clear(); _cache.clear();
} }
bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? false; bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? true;
} }
class StoreKeyNotFoundException implements Exception { class StoreKeyNotFoundException implements Exception {

View file

@ -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/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:logging/logging.dart';
final syncLinkedAlbumServiceProvider = Provider( final syncLinkedAlbumServiceProvider = Provider(
(ref) => SyncLinkedAlbumService( (ref) => SyncLinkedAlbumService(
@ -19,7 +20,9 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository; final DriftAlbumApiRepository _albumApiRepository;
const SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository); SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository);
final _log = Logger("SyncLinkedAlbumService");
Future<void> syncLinkedAlbums(String userId) async { Future<void> syncLinkedAlbums(String userId) async {
final selectedAlbums = await _localAlbumRepository.getBackupAlbums(); final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
@ -48,8 +51,12 @@ class SyncLinkedAlbumService {
} }
Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async { Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async {
for (final album in localAlbums) { try {
await _processLocalAlbum(album, ownerId); for (final album in localAlbums) {
await _processLocalAlbum(album, ownerId);
}
} catch (error, stackTrace) {
_log.severe("Error managing linked albums", error, stackTrace);
} }
} }

View file

@ -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<String, dynamic> toJson() {
return {'type': 'heartbeat', 'sendPort': sendPort};
}
}
class _CloseRequest extends _IsolateRequest {
const _CloseRequest();
Map<String, dynamic> toJson() {
return {'type': 'close'};
}
}
class _IsolateResponse {
const _IsolateResponse();
}
class _HeartbeatResponse extends _IsolateResponse {
final _LockStatus status;
const _HeartbeatResponse(this.status);
Map<String, dynamic> 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<SendPort> _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<bool> 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<void> _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<String, dynamic>) {
return null;
}
return switch (msg['type']) {
'heartbeat' => _HeartbeatRequest(msg['sendPort']),
'close' => const _CloseRequest(),
_ => null,
};
}
_IsolateResponse? _parseResponse(dynamic msg) {
if (msg is! Map<String, dynamic>) {
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());
}
}

View file

@ -4,7 +4,6 @@ import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.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(); return query.map((localAsset) => localAsset.toDto()).get();
} }
FutureOr<List<LocalAlbum>> 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();
}
} }

View file

@ -1,6 +1,8 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/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.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@ -26,6 +28,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Future<LocalAsset?> get(String id) => _assetSelectable(id).getSingleOrNull(); Future<LocalAsset?> get(String id) => _assetSelectable(id).getSingleOrNull();
Future<List<LocalAsset?>> getByChecksum(String checksum) {
final query = _db.localAssetEntity.select()..where((lae) => lae.checksum.equals(checksum));
return query.map((row) => row.toDto()).get();
}
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull(); Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
Future<void> updateHashes(Iterable<LocalAsset> hashes) { Future<void> updateHashes(Iterable<LocalAsset> hashes) {
@ -69,4 +77,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Future<int> getHashedCount() { Future<int> getHashedCount() {
return _db.managers.localAssetEntity.filter((e) => e.checksum.isNull().not()).count(); return _db.managers.localAssetEntity.filter((e) => e.checksum.isNull().not()).count();
} }
Future<List<LocalAlbum>> 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();
}
} }

View file

@ -55,6 +55,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
return _assetSelectable(id).getSingleOrNull(); return _assetSelectable(id).getSingleOrNull();
} }
Future<RemoteAsset?> getByChecksum(String checksum) {
final query = _db.remoteAssetEntity.select()..where((row) => row.checksum.equals(checksum));
return query.map((row) => row.toDto()).getSingleOrNull();
}
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) { Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) { if (asset.stackId == null) {
return Future.value([]); return Future.value([]);

View file

@ -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/generated/codegen_loader.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.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/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/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/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/locale_provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart';
@ -205,9 +205,9 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
// needs to be delayed so that EasyLocalization is working // needs to be delayed so that EasyLocalization is working
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
ref.read(backgroundServiceProvider).disableService(); ref.read(backgroundServiceProvider).disableService();
ref.read(driftBackgroundUploadFgService).enable(); ref.read(backgroundWorkerFgServiceProvider).enable();
} else { } else {
ref.read(driftBackgroundUploadFgService).disable(); ref.read(backgroundWorkerFgServiceProvider).disable();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
} }
}); });

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.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/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/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
@ -41,49 +44,13 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
Future<void> _handleMigration() async { Future<void> _handleMigration() async {
try { try {
if (widget.switchingToBeta) { await _performMigrationLogic().timeout(
final assetNotifier = ref.read(assetProvider.notifier); const Duration(minutes: 3),
if (assetNotifier.mounted) { onTimeout: () async {
assetNotifier.dispose(); await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
} await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
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);
if (mounted) { if (mounted) {
setState(() { setState(() {
@ -101,6 +68,52 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
} }
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(

View file

@ -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/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/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_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/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_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'; import 'package:immich_mobile/widgets/settings/settings_card.dart';
enum SettingSection { 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"), advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"),
assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"),
backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"),
@ -76,9 +76,9 @@ class _MobileLayout extends StatelessWidget {
if (Store.isBetaTimelineEnabled) if (Store.isBetaTimelineEnabled)
SettingsCard( SettingsCard(
icon: Icons.sync_outlined, icon: Icons.sync_outlined,
title: 'beta_sync'.tr(), title: 'sync_status'.tr(),
subtitle: 'beta_sync_subtitle'.tr(), subtitle: 'sync_status_subtitle'.tr(),
settingRoute: const BetaSyncSettingsRoute(), settingRoute: const SyncStatusRoute(),
), ),
] ]
: [ : [
@ -143,7 +143,7 @@ class _BetaLandscapeToggle extends HookWidget {
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
const SizedBox(height: 100, child: BetaTimelineListTile()), const SizedBox(height: 100, child: BetaTimelineListTile()),
if (Store.isBetaTimelineEnabled) const Expanded(child: BetaSyncSettings()), if (Store.isBetaTimelineEnabled) const Expanded(child: SyncStatusAndActions()),
], ],
); );
} }

View file

@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.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/entities/store.entity.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
@ -23,23 +22,14 @@ class SplashScreenPage extends StatefulHookConsumerWidget {
class SplashScreenPageState extends ConsumerState<SplashScreenPage> { class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final log = Logger("SplashScreenPage"); final log = Logger("SplashScreenPage");
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final lockManager = ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)); ref
.read(authProvider.notifier)
lockManager.requestHolderToClose(); .setOpenApiServiceEndpoint()
lockManager .then(logConnectionInfo)
.acquireLock() .whenComplete(() => resumeSession());
.timeout(const Duration(seconds: 5))
.whenComplete(
() => ref
.read(authProvider.notifier)
.setOpenApiServiceEndpoint()
.then(logConnectionInfo)
.whenComplete(() => resumeSession()),
);
} }
void logConnectionInfo(String? endpoint) { void logConnectionInfo(String? endpoint) {
@ -58,11 +48,23 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
if (accessToken != null && serverUrl != null && endpoint != null) { if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier); final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
(a) { (_) async {
try { try {
wsProvider.connect(); wsProvider.connect();
infoProvider.getServerInfo(); 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) { } catch (e) {
log.severe('Failed establishing connection to the server: $e'); log.severe('Failed establishing connection to the server: $e');
} }
@ -80,7 +82,16 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
return; return;
} }
// clean install - change the default of the flag
// current install not using beta timeline
if (context.router.current.name == SplashScreenRoute.name) { 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()); context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute());
} }

View file

@ -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/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/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/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.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/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/migration.dart';
@RoutePage() @RoutePage()
class TabShellPage extends ConsumerStatefulWidget { class TabShellPage extends ConsumerStatefulWidget {
@ -32,28 +26,6 @@ class TabShellPage extends ConsumerStatefulWidget {
} }
class _TabShellPageState extends ConsumerState<TabShellPage> { class _TabShellPageState extends ConsumerState<TabShellPage> {
@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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isScreenLandscape = context.orientation == Orientation.landscape; final isScreenLandscape = context.orientation == Orientation.landscape;

View file

@ -1,25 +1,25 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/translate_extensions.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() @RoutePage()
class BetaSyncSettingsPage extends StatelessWidget { class SyncStatusPage extends StatelessWidget {
const BetaSyncSettingsPage({super.key}); const SyncStatusPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
title: const Text("beta_sync").t(context: context), title: const Text("sync_status").t(context: context),
leading: IconButton( leading: IconButton(
onPressed: () => context.maybePop(true), onPressed: () => context.maybePop(true),
splashRadius: 24, splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded), icon: const Icon(Icons.arrow_back_ios_rounded),
), ),
), ),
body: const BetaSyncSettings(), body: const SyncStatusAndActions(),
); );
} }
} }

View file

@ -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<void> _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<void> _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<void> _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<List<LocalAsset?>>(
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<LocalAsset>() ?? [];
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<RemoteAsset?>(
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)),
),
],
),
);
}
}

View file

@ -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),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_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'; 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/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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/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/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -41,6 +43,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final buttonContext = ActionButtonContext( final buttonContext = ActionButtonContext(
asset: asset, asset: asset,
@ -49,6 +52,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
isTrashEnabled: isTrashEnable, isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView, isInLockedView: isInLockedView,
currentAlbum: currentAlbum, currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer, source: ActionSource.viewer,
); );
@ -122,6 +126,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
} }
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(currentAssetNotifier);
@ -132,10 +140,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo); final cameraTitle = _getCameraInfoTitle(exifInfo);
Future<void> editDateTime() async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
return SliverList.list( return SliverList.list(
children: [ children: [
// Asset Date and Time // Asset Date and Time
@ -143,7 +147,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
title: _getDateTime(context, asset), title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null, 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), if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
const SheetPeopleDetails(), const SheetPeopleDetails(),

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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_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/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_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';

View file

@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/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/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_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_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/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.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/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/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -51,6 +54,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider); final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
Future<void> addAssetsToAlbum(RemoteAlbum album) async { Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets; final selectedAssets = multiselect.selectedAssets;
@ -88,6 +92,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85, maxChildSize: 0.85,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
actions: [ actions: [
if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[
const AdvancedInfoActionButton(source: ActionSource.timeline),
],
const ShareActionButton(source: ActionSource.timeline), const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[ if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.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/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
@ -125,94 +124,49 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
} }
Future<void> _safeRun(Future<void> action, String debugName) async {
if (!_shouldContinueOperation()) {
return;
}
try {
await action;
} catch (e, stackTrace) {
_log.warning("Error during $debugName operation", e, stackTrace);
}
}
Future<void> _handleBetaTimelineResume() async { Future<void> _handleBetaTimelineResume() async {
_ref.read(backupProvider.notifier).cancelBackup(); _ref.read(backupProvider.notifier).cancelBackup();
final lockManager = _ref.read(isolateLockManagerProvider(kIsolateLockManagerPort));
// Give isolates time to complete any ongoing database transactions // Give isolates time to complete any ongoing database transactions
await Future.delayed(const Duration(milliseconds: 500)); 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 backgroundManager = _ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
try { try {
// Run operations sequentially with state checks and error handling for each // Run operations sequentially with state checks and error handling for each
if (_shouldContinueOperation()) { await _safeRun(backgroundManager.syncLocal(), "syncLocal");
try { await _safeRun(backgroundManager.syncRemote(), "syncRemote");
await backgroundManager.syncLocal(); await _safeRun(backgroundManager.hashAssets(), "hashAssets");
} catch (e, stackTrace) { if (isAlbumLinkedSyncEnable) {
_log.warning("Failed syncLocal: $e", e, stackTrace); await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
}
}
// 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);
}
}
} }
// Handle backup resume only if still active // Handle backup resume only if still active
if (_shouldContinueOperation()) { if (isEnableBackup) {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); final currentUser = _ref.read(currentUserProvider);
if (currentUser != null) {
if (isEnableBackup) { await _safeRun(
final currentUser = _ref.read(currentUserProvider); _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
if (currentUser != null) { "handleBackupResume",
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);
}
}
} }
} }
} catch (e, stackTrace) { } catch (e, stackTrace) {
_log.severe("Error during background sync", 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<AppLifeCycleEnum> {
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup(); _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(); _ref.read(websocketProvider.notifier).disconnect();
@ -312,7 +244,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} catch (_) {} } catch (_) {}
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
_ref.read(isolateLockManagerProvider(kIsolateLockManagerPort)).releaseLock();
return; return;
} }

View file

@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.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'; import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) { final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
@ -19,7 +18,3 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
ref.onDispose(manager.cancel); ref.onDispose(manager.cancel);
return manager; return manager;
}); });
final isolateLockManagerProvider = Provider.family<IsolateLockManager, String>((ref, name) {
return IsolateLockManager(portName: name);
});

View file

@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.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/album.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.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/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_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/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/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.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:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final driftBackgroundUploadFgService = Provider((ref) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) { final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier( return BackupNotifier(
ref.watch(backupServiceProvider), ref.watch(backupServiceProvider),

View file

@ -6,11 +6,11 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.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/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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/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/providers/user.provider.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -380,5 +380,5 @@ final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<
ref, ref,
assetId, assetId,
) { ) {
return ref.read(backupRepositoryProvider).getSourceAlbums(assetId); return ref.read(localAssetRepository).getSourceAlbums(assetId, backupSelection: BackupSelection.selected);
}); });

View file

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.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/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.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/action.service.dart';
import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/services/timeline.service.dart';
@ -115,6 +117,16 @@ class ActionNotifier extends Notifier<void> {
}; };
} }
Future<ActionResult> 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<ActionResult> shareLink(ActionSource source, BuildContext context) async { Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
final ids = _getRemoteIdsForSource(source); final ids = _getRemoteIdsForSource(source);
try { try {

View file

@ -1,7 +1,11 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/native_sync_api.g.dart';
import 'package:immich_mobile/platform/thumbnail_api.g.dart'; import 'package:immich_mobile/platform/thumbnail_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi()); final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final thumbnailApi = ThumbnailApi(); final thumbnailApi = ThumbnailApi();

View file

@ -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/person_result.page.dart';
import 'package:immich_mobile/pages/search/recently_taken.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/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/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/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.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_album_options.page.dart';
import 'package:immich_mobile/presentation/pages/drift_archive.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_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_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.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: ChangeExperienceRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftUploadDetailRoute.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: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
@ -343,6 +344,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftFilterImageRoute.page), AutoRoute(page: DriftFilterImageRoute.page),
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupAssetDetailRoute.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 // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),

View file

@ -403,6 +403,43 @@ class ArchiveRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [AssetTroubleshootPage]
class AssetTroubleshootRoute extends PageRouteInfo<AssetTroubleshootRouteArgs> {
AssetTroubleshootRoute({
Key? key,
required BaseAsset asset,
List<PageRouteInfo>? 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<AssetTroubleshootRouteArgs>();
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 /// generated route for
/// [AssetViewerPage] /// [AssetViewerPage]
class AssetViewerRoute extends PageRouteInfo<AssetViewerRouteArgs> { class AssetViewerRoute extends PageRouteInfo<AssetViewerRouteArgs> {
@ -509,22 +546,6 @@ class BackupOptionsRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [BetaSyncSettingsPage]
class BetaSyncSettingsRoute extends PageRouteInfo<void> {
const BetaSyncSettingsRoute({List<PageRouteInfo>? children})
: super(BetaSyncSettingsRoute.name, initialChildren: children);
static const String name = 'BetaSyncSettingsRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const BetaSyncSettingsPage();
},
);
}
/// generated route for /// generated route for
/// [ChangeExperiencePage] /// [ChangeExperiencePage]
class ChangeExperienceRoute extends PageRouteInfo<ChangeExperienceRouteArgs> { class ChangeExperienceRoute extends PageRouteInfo<ChangeExperienceRouteArgs> {
@ -2629,6 +2650,22 @@ class SplashScreenRoute extends PageRouteInfo<void> {
); );
} }
/// generated route for
/// [SyncStatusPage]
class SyncStatusRoute extends PageRouteInfo<void> {
const SyncStatusRoute({List<PageRouteInfo>? children})
: super(SyncStatusRoute.name, initialChildren: children);
static const String name = 'SyncStatusRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const SyncStatusPage();
},
);
}
/// generated route for /// generated route for
/// [TabControllerPage] /// [TabControllerPage]
class TabControllerRoute extends PageRouteInfo<void> { class TabControllerRoute extends PageRouteInfo<void> {

View file

@ -46,7 +46,7 @@ enum AppSettingsEnum<T> {
syncAlbums<bool>(StoreKey.syncAlbums, null, false), syncAlbums<bool>(StoreKey.syncAlbums, null, false),
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false), autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
photoManagerCustomFilter<bool>(StoreKey.photoManagerCustomFilter, null, true), photoManagerCustomFilter<bool>(StoreKey.photoManagerCustomFilter, null, true),
betaTimeline<bool>(StoreKey.betaTimeline, null, false), betaTimeline<bool>(StoreKey.betaTimeline, null, true),
enableBackup<bool>(StoreKey.enableBackup, null, false), enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false), useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),

View file

@ -1,6 +1,8 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/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_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_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/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/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class ActionButtonContext { class ActionButtonContext {
final BaseAsset asset; final BaseAsset asset;
@ -24,6 +25,7 @@ class ActionButtonContext {
final bool isTrashEnabled; final bool isTrashEnabled;
final bool isInLockedView; final bool isInLockedView;
final RemoteAlbum? currentAlbum; final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting;
final ActionSource source; final ActionSource source;
const ActionButtonContext({ const ActionButtonContext({
@ -33,11 +35,13 @@ class ActionButtonContext {
required this.isTrashEnabled, required this.isTrashEnabled,
required this.isInLockedView, required this.isInLockedView,
required this.currentAlbum, required this.currentAlbum,
required this.advancedTroubleshooting,
required this.source, required this.source,
}); });
} }
enum ActionButtonType { enum ActionButtonType {
advancedInfo,
share, share,
shareLink, shareLink,
archive, archive,
@ -55,6 +59,7 @@ enum ActionButtonType {
bool shouldShow(ActionButtonContext context) { bool shouldShow(ActionButtonContext context) {
return switch (this) { return switch (this) {
ActionButtonType.advancedInfo => context.advancedTroubleshooting,
ActionButtonType.share => true, ActionButtonType.share => true,
ActionButtonType.shareLink => ActionButtonType.shareLink =>
!context.isInLockedView && // !context.isInLockedView && //
@ -115,6 +120,7 @@ enum ActionButtonType {
Widget buildButton(ActionButtonContext context) { Widget buildButton(ActionButtonContext context) {
return switch (this) { return switch (this) {
ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source),
ActionButtonType.share => ShareActionButton(source: context.source), ActionButtonType.share => ShareActionButton(source: context.source),
ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), ActionButtonType.shareLink => ShareLinkActionButton(source: context.source),
ActionButtonType.archive => ArchiveActionButton(source: context.source), ActionButtonType.archive => ArchiveActionButton(source: context.source),
@ -138,6 +144,7 @@ enum ActionButtonType {
class ActionButtonBuilder { class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = [ static const List<ActionButtonType> _actionTypes = [
ActionButtonType.advancedInfo,
ActionButtonType.share, ActionButtonType.share,
ActionButtonType.shareLink, ActionButtonType.shareLink,
ActionButtonType.likeActivity, ActionButtonType.likeActivity,

View file

@ -90,7 +90,7 @@ abstract final class Bootstrap {
} }
static Future<void> initDomain(Isar db, Drift drift, DriftLogger logDb, {bool shouldBufferLogs = true}) async { static Future<void> 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); final IStoreRepository storeRepo = isBeta ? DriftStoreRepository(drift) : IsarStoreRepository(db);
await StoreService.init(storeRepository: storeRepo); await StoreService.init(storeRepository: storeRepo);

View file

@ -5,7 +5,6 @@ import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/foundation.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/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.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/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/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:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 14; const int targetVersion = 15;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async { Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null; final hasVersion = Store.tryGet(StoreKey.version) != null;
final int version = Store.get(StoreKey.version, targetVersion); final int version = Store.get(StoreKey.version, targetVersion);
if (version < 9) { if (version < 9) {
await Store.put(StoreKey.version, targetVersion); await Store.put(StoreKey.version, targetVersion);
final value = await db.storeValues.get(StoreKey.currentUser.id); final value = await db.storeValues.get(StoreKey.currentUser.id);
@ -68,6 +61,26 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await Store.populateCache(); 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) { if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion); await Store.put(StoreKey.version, targetVersion);
return; return;
@ -80,6 +93,35 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
} }
} }
Future<bool> _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<void> _migrateTo(Isar db, int version) async { Future<void> _migrateTo(Isar db, int version) async {
await Store.delete(StoreKey.assetETag); await Store.delete(StoreKey.assetETag);
await db.writeTxn(() async { await db.writeTxn(() async {
@ -266,21 +308,26 @@ class _DeviceAsset {
const _DeviceAsset({required this.assetId, this.hash, this.dateTime}); const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
} }
Future<List<void>> runNewSync(WidgetRef ref, {bool full = false}) { Future<void> resetDriftDatabase(Drift drift) async {
ref.read(backupProvider.notifier).cancelBackup(); // 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); await database.customStatement('PRAGMA user_version = 0');
final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); 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([ // Refresh all stream queries
backgroundManager.syncLocal(full: full).then((_) { database.notifyUpdates({for (final table in database.allTables) TableUpdate.onTable(table)});
Logger("runNewSync").fine("Hashing assets after syncLocal"); });
return backgroundManager.hashAssets();
}),
backgroundManager.syncRemote().then((_) {
if (isAlbumLinkedSyncEnable) {
return backgroundManager.syncLinkedAlbum();
}
}),
]);
} }

View file

@ -90,11 +90,11 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
minLeadingWidth: 50, minLeadingWidth: 50,
leading: GestureDetector( leading: GestureDetector(
onTap: pickUserProfileImage, onTap: pickUserProfileImage,
onDoubleTap: toggleReadonlyMode, onLongPress: toggleReadonlyMode,
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
buildUserProfileImage(), AbsorbPointer(child: buildUserProfileImage()),
if (!isReadonlyModeEnabled) if (!isReadonlyModeEnabled)
Positioned( Positioned(
bottom: -5, bottom: -5,

View file

@ -157,7 +157,7 @@ class _ProfileIndicator extends ConsumerWidget {
return InkWell( return InkWell(
onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()), onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
onDoubleTap: () => toggleReadonlyMode(), onLongPress: () => toggleReadonlyMode(),
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge( child: Badge(
label: Container( label: Container(
@ -173,7 +173,7 @@ class _ProfileIndicator extends ConsumerWidget {
? const Icon(Icons.face_outlined, size: widgetSize) ? const Icon(Icons.face_outlined, size: widgetSize)
: Semantics( : Semantics(
label: "logged_in_as".tr(namedArgs: {"user": user.name}), 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)),
), ),
), ),
); );

View file

@ -10,9 +10,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.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'; serverEndpointController.text = 'http://10.1.15.216:2283/api';
} }
Future<void> 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 { login() async {
TextInput.finishAutofillContext(); TextInput.finishAutofillContext();
@ -178,7 +192,7 @@ class LoginForm extends HookConsumerWidget {
final isBeta = Store.isBetaTimelineEnabled; final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) { if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
handleSyncFlow();
context.replaceRoute(const TabShellRoute()); context.replaceRoute(const TabShellRoute());
return; return;
} }
@ -276,6 +290,7 @@ class LoginForm extends HookConsumerWidget {
} }
if (isBeta) { if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
handleSyncFlow();
context.replaceRoute(const TabShellRoute()); context.replaceRoute(const TabShellRoute());
return; return;
} }

View file

@ -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<List<dynamic>> 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<void> 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<void> 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<void> clearFileCache() async {
await ref.read(storageRepositoryProvider).clearCache();
}
Future<void> resetSqliteDb(BuildContext context, Future<void> 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<List<dynamic>>(
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),
),
),
);
}
}

View file

@ -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<void> 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<void> clearFileCache() async {
await ref.read(storageRepositoryProvider).clearCache();
}
Future<void> 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<List<dynamic>> 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,
),
),
],
),
),
],
);
},
);
}
}

View file

@ -107,7 +107,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | *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* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} |
*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | *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 | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | *DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} |
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *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* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | *DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} |

View file

@ -729,9 +729,9 @@ class AssetsApi {
return null; 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]. /// 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: /// Parameters:
/// ///

View file

@ -127,4 +127,138 @@ class DeprecatedApi {
} }
return null; 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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['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<AssetMediaResponseDto?> 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;
}
} }

View file

@ -53,12 +53,15 @@ class TimelineApi {
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withCoordinates:
/// Include location data in the response
///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners /// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> 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<Response> 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 // ignore: prefer_const_declarations
final apiPath = r'/timeline/bucket'; final apiPath = r'/timeline/bucket';
@ -100,6 +103,9 @@ class TimelineApi {
if (visibility != null) { if (visibility != null) {
queryParams.addAll(_queryParams('', 'visibility', visibility)); queryParams.addAll(_queryParams('', 'visibility', visibility));
} }
if (withCoordinates != null) {
queryParams.addAll(_queryParams('', 'withCoordinates', withCoordinates));
}
if (withPartners != null) { if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners)); queryParams.addAll(_queryParams('', 'withPartners', withPartners));
} }
@ -156,13 +162,16 @@ class TimelineApi {
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withCoordinates:
/// Include location data in the response
///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners /// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<TimeBucketAssetResponseDto?> 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 { Future<TimeBucketAssetResponseDto?> 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, withPartners: withPartners, withStacked: withStacked, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@ -210,12 +219,15 @@ class TimelineApi {
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withCoordinates:
/// Include location data in the response
///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners /// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> 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<Response> 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 // ignore: prefer_const_declarations
final apiPath = r'/timeline/buckets'; final apiPath = r'/timeline/buckets';
@ -256,6 +268,9 @@ class TimelineApi {
if (visibility != null) { if (visibility != null) {
queryParams.addAll(_queryParams('', 'visibility', visibility)); queryParams.addAll(_queryParams('', 'visibility', visibility));
} }
if (withCoordinates != null) {
queryParams.addAll(_queryParams('', 'withCoordinates', withCoordinates));
}
if (withPartners != null) { if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners)); queryParams.addAll(_queryParams('', 'withPartners', withPartners));
} }
@ -309,13 +324,16 @@ class TimelineApi {
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withCoordinates:
/// Include location data in the response
///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners /// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<List<TimeBucketsResponseDto>?> 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 { Future<List<TimeBucketsResponseDto>?> 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, withPartners: withPartners, withStacked: withStacked, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View file

@ -21,8 +21,10 @@ class TimeBucketAssetResponseDto {
this.isFavorite = const [], this.isFavorite = const [],
this.isImage = const [], this.isImage = const [],
this.isTrashed = const [], this.isTrashed = const [],
this.latitude = const [],
this.livePhotoVideoId = const [], this.livePhotoVideoId = const [],
this.localOffsetHours = const [], this.localOffsetHours = const [],
this.longitude = const [],
this.ownerId = const [], this.ownerId = const [],
this.projectionType = const [], this.projectionType = const [],
this.ratio = const [], this.ratio = const [],
@ -55,12 +57,18 @@ class TimeBucketAssetResponseDto {
/// Array indicating whether each asset is in the trash /// Array indicating whether each asset is in the trash
List<bool> isTrashed; List<bool> isTrashed;
/// Array of latitude coordinates extracted from EXIF GPS data
List<num?> latitude;
/// Array of live photo video asset IDs (null for non-live photos) /// Array of live photo video asset IDs (null for non-live photos)
List<String?> livePhotoVideoId; List<String?> 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. /// 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<num> localOffsetHours; List<num> localOffsetHours;
/// Array of longitude coordinates extracted from EXIF GPS data
List<num?> longitude;
/// Array of owner IDs for each asset /// Array of owner IDs for each asset
List<String> ownerId; List<String> ownerId;
@ -89,8 +97,10 @@ class TimeBucketAssetResponseDto {
_deepEquality.equals(other.isFavorite, isFavorite) && _deepEquality.equals(other.isFavorite, isFavorite) &&
_deepEquality.equals(other.isImage, isImage) && _deepEquality.equals(other.isImage, isImage) &&
_deepEquality.equals(other.isTrashed, isTrashed) && _deepEquality.equals(other.isTrashed, isTrashed) &&
_deepEquality.equals(other.latitude, latitude) &&
_deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) && _deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
_deepEquality.equals(other.localOffsetHours, localOffsetHours) && _deepEquality.equals(other.localOffsetHours, localOffsetHours) &&
_deepEquality.equals(other.longitude, longitude) &&
_deepEquality.equals(other.ownerId, ownerId) && _deepEquality.equals(other.ownerId, ownerId) &&
_deepEquality.equals(other.projectionType, projectionType) && _deepEquality.equals(other.projectionType, projectionType) &&
_deepEquality.equals(other.ratio, ratio) && _deepEquality.equals(other.ratio, ratio) &&
@ -109,8 +119,10 @@ class TimeBucketAssetResponseDto {
(isFavorite.hashCode) + (isFavorite.hashCode) +
(isImage.hashCode) + (isImage.hashCode) +
(isTrashed.hashCode) + (isTrashed.hashCode) +
(latitude.hashCode) +
(livePhotoVideoId.hashCode) + (livePhotoVideoId.hashCode) +
(localOffsetHours.hashCode) + (localOffsetHours.hashCode) +
(longitude.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(projectionType.hashCode) + (projectionType.hashCode) +
(ratio.hashCode) + (ratio.hashCode) +
@ -119,7 +131,7 @@ class TimeBucketAssetResponseDto {
(visibility.hashCode); (visibility.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -131,8 +143,10 @@ class TimeBucketAssetResponseDto {
json[r'isFavorite'] = this.isFavorite; json[r'isFavorite'] = this.isFavorite;
json[r'isImage'] = this.isImage; json[r'isImage'] = this.isImage;
json[r'isTrashed'] = this.isTrashed; json[r'isTrashed'] = this.isTrashed;
json[r'latitude'] = this.latitude;
json[r'livePhotoVideoId'] = this.livePhotoVideoId; json[r'livePhotoVideoId'] = this.livePhotoVideoId;
json[r'localOffsetHours'] = this.localOffsetHours; json[r'localOffsetHours'] = this.localOffsetHours;
json[r'longitude'] = this.longitude;
json[r'ownerId'] = this.ownerId; json[r'ownerId'] = this.ownerId;
json[r'projectionType'] = this.projectionType; json[r'projectionType'] = this.projectionType;
json[r'ratio'] = this.ratio; json[r'ratio'] = this.ratio;
@ -175,12 +189,18 @@ class TimeBucketAssetResponseDto {
isTrashed: json[r'isTrashed'] is Iterable isTrashed: json[r'isTrashed'] is Iterable
? (json[r'isTrashed'] as Iterable).cast<bool>().toList(growable: false) ? (json[r'isTrashed'] as Iterable).cast<bool>().toList(growable: false)
: const [], : const [],
latitude: json[r'latitude'] is Iterable
? (json[r'latitude'] as Iterable).cast<num>().toList(growable: false)
: const [],
livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false) ? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
localOffsetHours: json[r'localOffsetHours'] is Iterable localOffsetHours: json[r'localOffsetHours'] is Iterable
? (json[r'localOffsetHours'] as Iterable).cast<num>().toList(growable: false) ? (json[r'localOffsetHours'] as Iterable).cast<num>().toList(growable: false)
: const [], : const [],
longitude: json[r'longitude'] is Iterable
? (json[r'longitude'] as Iterable).cast<num>().toList(growable: false)
: const [],
ownerId: json[r'ownerId'] is Iterable ownerId: json[r'ownerId'] is Iterable
? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false) ? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],

View file

@ -81,6 +81,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -110,6 +111,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -124,6 +126,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -141,6 +144,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -156,6 +160,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -171,6 +176,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -188,6 +194,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -203,6 +210,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -218,6 +226,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -233,6 +242,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -248,6 +258,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -265,6 +276,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -280,6 +292,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -295,6 +308,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -312,6 +326,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -327,6 +342,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -342,6 +358,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: true, isInLockedView: true,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -359,6 +376,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -374,6 +392,7 @@ void main() {
isTrashEnabled: false, isTrashEnabled: false,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -391,6 +410,7 @@ void main() {
isTrashEnabled: false, isTrashEnabled: false,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -406,6 +426,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -423,6 +444,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -440,6 +462,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -457,6 +480,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -472,6 +496,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -489,6 +514,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -506,6 +532,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -520,6 +547,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -537,6 +565,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -552,6 +581,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -567,6 +597,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -581,12 +612,45 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); 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', () { group('ActionButtonType.buildButton', () {
@ -602,6 +666,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
}); });
@ -617,6 +682,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
@ -639,6 +705,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -658,6 +725,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -675,6 +743,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -693,6 +762,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@ -705,6 +775,7 @@ void main() {
isTrashEnabled: true, isTrashEnabled: true,
isInLockedView: false, isInLockedView: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );

View file

@ -2504,7 +2504,8 @@
"description": "This endpoint requires the `asset.download` permission." "description": "This endpoint requires the `asset.download` permission."
}, },
"put": { "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", "operationId": "replaceAsset",
"parameters": [ "parameters": [
{ {
@ -2566,12 +2567,14 @@
"api_key": [] "api_key": []
} }
], ],
"summary": "replaceAsset", "summary": "Replace the asset with new file, without changing its id",
"tags": [ "tags": [
"Assets" "Assets",
"Deprecated"
], ],
"x-immich-lifecycle": { "x-immich-lifecycle": {
"addedAt": "v1.106.0" "addedAt": "v1.106.0",
"deprecatedAt": "v1.142.0"
}, },
"x-immich-permission": "asset.replace" "x-immich-permission": "asset.replace"
} }
@ -8903,6 +8906,15 @@
"$ref": "#/components/schemas/AssetVisibility" "$ref": "#/components/schemas/AssetVisibility"
} }
}, },
{
"name": "withCoordinates",
"required": false,
"in": "query",
"description": "Include location data in the response",
"schema": {
"type": "boolean"
}
},
{ {
"name": "withPartners", "name": "withPartners",
"required": false, "required": false,
@ -9048,6 +9060,15 @@
"$ref": "#/components/schemas/AssetVisibility" "$ref": "#/components/schemas/AssetVisibility"
} }
}, },
{
"name": "withCoordinates",
"required": false,
"in": "query",
"description": "Include location data in the response",
"schema": {
"type": "boolean"
}
},
{ {
"name": "withPartners", "name": "withPartners",
"required": false, "required": false,
@ -17137,6 +17158,14 @@
}, },
"type": "array" "type": "array"
}, },
"latitude": {
"description": "Array of latitude coordinates extracted from EXIF GPS data",
"items": {
"nullable": true,
"type": "number"
},
"type": "array"
},
"livePhotoVideoId": { "livePhotoVideoId": {
"description": "Array of live photo video asset IDs (null for non-live photos)", "description": "Array of live photo video asset IDs (null for non-live photos)",
"items": { "items": {
@ -17152,6 +17181,14 @@
}, },
"type": "array" "type": "array"
}, },
"longitude": {
"description": "Array of longitude coordinates extracted from EXIF GPS data",
"items": {
"nullable": true,
"type": "number"
},
"type": "array"
},
"ownerId": { "ownerId": {
"description": "Array of owner IDs for each asset", "description": "Array of owner IDs for each asset",
"items": { "items": {

View file

@ -1576,10 +1576,14 @@ export type TimeBucketAssetResponseDto = {
isImage: boolean[]; isImage: boolean[];
/** Array indicating whether each asset is in the trash */ /** Array indicating whether each asset is in the trash */
isTrashed: boolean[]; 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) */ /** Array of live photo video asset IDs (null for non-live photos) */
livePhotoVideoId: (string | null)[]; 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. */ /** 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[]; localOffsetHours: number[];
/** Array of longitude coordinates extracted from EXIF GPS data */
longitude?: (number | null)[];
/** Array of owner IDs for each asset */ /** Array of owner IDs for each asset */
ownerId: string[]; ownerId: string[];
/** Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL") */ /** 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 }: { export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: {
id: string; id: string;
@ -4310,7 +4314,7 @@ export function tagAssets({ id, bulkIdsDto }: {
/** /**
* This endpoint requires the `asset.read` permission. * 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; albumId?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
@ -4322,6 +4326,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
timeBucket: string; timeBucket: string;
userId?: string; userId?: string;
visibility?: AssetVisibility; visibility?: AssetVisibility;
withCoordinates?: boolean;
withPartners?: boolean; withPartners?: boolean;
withStacked?: boolean; withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@ -4340,6 +4345,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
timeBucket, timeBucket,
userId, userId,
visibility, visibility,
withCoordinates,
withPartners, withPartners,
withStacked withStacked
}))}`, { }))}`, {
@ -4349,7 +4355,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
/** /**
* This endpoint requires the `asset.read` permission. * 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; albumId?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
@ -4360,6 +4366,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
tagId?: string; tagId?: string;
userId?: string; userId?: string;
visibility?: AssetVisibility; visibility?: AssetVisibility;
withCoordinates?: boolean;
withPartners?: boolean; withPartners?: boolean;
withStacked?: boolean; withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@ -4377,6 +4384,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
tagId, tagId,
userId, userId,
visibility, visibility,
withCoordinates,
withPartners, withPartners,
withStacked withStacked
}))}`, { }))}`, {

1104
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -42,11 +42,11 @@
- ⚠️ Цей проєкт перебуває **в дуже активній** розробці. - ⚠️ Цей проєкт перебуває **в дуже активній** розробці.
- ⚠️ Очікуйте безліч помилок і глобальних змін. - ⚠️ Очікуйте безліч помилок і глобальних змін.
- ⚠️ **Не використовуйте цей додаток як єдине сховище своїх фото та відео.** - ⚠️ **Не використовуйте цей застосунок як єдине сховище своїх фото та відео.**
- ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео! - ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео!
> [!NOTE] > [!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 @@
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Статус перекладів" /> <img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Статус перекладів" />
</a> </a>
## Активність репозитарію ## Активність репозиторію
![Діяльність](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Зображення аналітики Repobeats") ![Діяльність](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Зображення аналітики Repobeats")

View file

@ -1,5 +1,5 @@
# dev build # 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 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
@ -77,7 +77,7 @@ RUN apt-get update \
RUN dart --disable-analytics RUN dart --disable-analytics
# production-builder-base image # 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 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
COREPACK_HOME=/tmp 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 pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
# prod base image # 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 WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
@ -125,7 +125,7 @@ ENV NODE_ENV=production \
COPY --from=server-prod /output/server-pruned ./server COPY --from=server-prod /output/server-pruned ./server
COPY --from=web-prod /usr/src/app/web/build /build/www COPY --from=web-prod /usr/src/app/web/build /build/www
COPY --from=cli-prod /output/cli-pruned ./cli 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 /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE

View file

@ -8,7 +8,7 @@ else
echo "skipping libmimalloc - path not found $lib_path" echo "skipping libmimalloc - path not found $lib_path"
fi fi
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib" 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() { read_file_and_export() {
fname="${!1}" fname="${!1}"

View file

@ -37,14 +37,12 @@
"@nestjs/bullmq": "^11.0.1", "@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4", "@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4", "@nestjs/core": "^11.0.4",
"@nestjs/event-emitter": "^3.0.0",
"@nestjs/platform-express": "^11.0.4", "@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4", "@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0", "@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.0.2", "@nestjs/swagger": "^11.0.2",
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.62.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.203.0", "@opentelemetry/exporter-prometheus": "^0.203.0",
"@opentelemetry/instrumentation-http": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.203.0",
@ -108,20 +106,16 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0", "tailwindcss-preset-email": "^1.4.0",
"thumbhash": "^0.1.1", "thumbhash": "^0.1.1",
"typeorm": "^0.3.17",
"ua-parser-js": "^2.0.0", "ua-parser-js": "^2.0.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"validator": "^13.12.0" "validator": "^13.12.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@nestjs/cli": "^11.0.2", "@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.4", "@nestjs/testing": "^11.0.4",
"@swc/core": "^1.4.14", "@swc/core": "^1.4.14",
"@testcontainers/postgresql": "^11.0.0",
"@testcontainers/redis": "^11.0.0",
"@types/archiver": "^6.0.0", "@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2", "@types/async-lock": "^1.4.2",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
@ -146,29 +140,23 @@
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@types/validator": "^13.15.2", "@types/validator": "^13.15.2",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"canvas": "^3.1.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^60.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"node-addon-api": "^8.3.1",
"node-gyp": "^11.2.0", "node-gyp": "^11.2.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.0.2", "prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"rimraf": "^6.0.0",
"source-map-support": "^0.5.21",
"sql-formatter": "^15.0.0", "sql-formatter": "^15.0.0",
"supertest": "^7.1.0", "supertest": "^7.1.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"testcontainers": "^11.0.0", "testcontainers": "^11.0.0",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"unplugin-swc": "^1.4.5", "unplugin-swc": "^1.4.5",
"utimes": "^5.2.1",
"vite-tsconfig-paths": "^5.0.0", "vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },

View file

@ -96,8 +96,9 @@ export class AssetMediaController {
@Put(':id/original') @Put(':id/original')
@UseInterceptors(FileUploadInterceptor) @UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@EndpointLifecycle({ addedAt: 'v1.106.0' }) @EndpointLifecycle({
@ApiOperation({ addedAt: 'v1.106.0',
deprecatedAt: 'v1.142.0',
summary: 'replaceAsset', summary: 'replaceAsset',
description: 'Replace the asset with new file, without changing its id', description: 'Replace the asset with new file, without changing its id',
}) })

View file

@ -1,5 +1,5 @@
import { SetMetadata, applyDecorators } from '@nestjs/common'; 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 _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
@ -159,12 +159,21 @@ type LifecycleMetadata = {
deprecatedAt?: LifecycleRelease; 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 })]; const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })];
if (deprecatedAt) { if (deprecatedAt) {
decorators.push( decorators.push(
ApiTags('Deprecated'), ApiTags('Deprecated'),
ApiOperation({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt }), ApiOperation({
deprecated: true,
description: DEPRECATED_IN_PREFIX + deprecatedAt + (description ? `. ${description}` : ''),
...options,
}),
); );
} }

View file

@ -53,6 +53,12 @@ export class TimeBucketDto {
description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)', description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
}) })
visibility?: AssetVisibility; visibility?: AssetVisibility;
@ValidateBoolean({
optional: true,
description: 'Include location data in the response',
})
withCoordinates?: boolean;
} }
export class TimeBucketAssetDto extends TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto {
@ -185,6 +191,22 @@ export class TimeBucketAssetResponseDto {
description: 'Array of country names extracted from EXIF GPS data', description: 'Array of country names extracted from EXIF GPS data',
}) })
country!: (string | null)[]; 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 { export class TimeBucketsResponseDto {

View file

@ -60,6 +60,7 @@ interface AssetBuilderOptions {
status?: AssetStatus; status?: AssetStatus;
assetType?: AssetType; assetType?: AssetType;
visibility?: AssetVisibility; visibility?: AssetVisibility;
withCoordinates?: boolean;
} }
export interface TimeBucketOptions extends AssetBuilderOptions { export interface TimeBucketOptions extends AssetBuilderOptions {
@ -629,6 +630,7 @@ export class AssetRepository {
) )
.as('ratio'), .as('ratio'),
]) ])
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility) .$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) .$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', ['status']), sql.lit('{}')).as('status'),
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'), 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) => .$if(!!options.withStacked, (qb) =>
qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')), qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
), ),

View file

@ -81,7 +81,7 @@ type EventMap = {
StackDeleteAll: [{ stackIds: string[]; userId: string }]; StackDeleteAll: [{ stackIds: string[]; userId: string }];
// user events // user events
UserSignup: [{ notify: boolean; id: string; tempPassword?: string }]; UserSignup: [{ notify: boolean; id: string; password?: string }];
// websocket events // websocket events
WebsocketConnect: [{ userId: string }]; WebsocketConnect: [{ userId: string }];

View file

@ -1660,5 +1660,16 @@ describe(MetadataService.name, () => {
expect(result?.tag).toBe('GPSDateTime'); expect(result?.tag).toBe('GPSDateTime');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z'); 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');
});
}); });
}); });

View file

@ -39,9 +39,9 @@ const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
'SubSecCreateDate', 'SubSecCreateDate',
'SubSecMediaCreateDate', 'SubSecMediaCreateDate',
'DateTimeOriginal', 'DateTimeOriginal',
'CreationDate',
'CreateDate', 'CreateDate',
'MediaCreateDate', 'MediaCreateDate',
'CreationDate',
'DateTimeCreated', 'DateTimeCreated',
'GPSDateTime', 'GPSDateTime',
'DateTimeUTC', 'DateTimeUTC',

View file

@ -147,7 +147,7 @@ describe(NotificationService.name, () => {
await sut.onUserSignup({ id: '', notify: true }); await sut.onUserSignup({ id: '', notify: true });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NotifyUserSignup, name: JobName.NotifyUserSignup,
data: { id: '', tempPassword: undefined }, data: { id: '', password: undefined },
}); });
}); });
}); });

View file

@ -191,9 +191,9 @@ export class NotificationService extends BaseService {
} }
@OnEvent({ name: 'UserSignup' }) @OnEvent({ name: 'UserSignup' })
async onUserSignup({ notify, id, tempPassword }: ArgOf<'UserSignup'>) { async onUserSignup({ notify, id, password: password }: ArgOf<'UserSignup'>) {
if (notify) { 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 }; 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 }) @OnJob({ name: JobName.NotifyUserSignup, queue: QueueName.Notification })
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NotifyUserSignup>) { async handleUserSignup({ id, password }: JobOf<JobName.NotifyUserSignup>) {
const user = await this.userRepository.get(id, { withDeleted: false }); const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) { if (!user) {
return JobStatus.Skipped; return JobStatus.Skipped;
@ -327,7 +265,7 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
displayName: user.name, displayName: user.name,
username: user.email, username: user.email,
password: tempPassword, password,
}, },
customTemplate: templates.email.welcomeTemplate, customTemplate: templates.email.welcomeTemplate,
}); });

View file

@ -38,7 +38,7 @@ export class UserAdminService extends BaseService {
await this.eventRepository.emit('UserSignup', { await this.eventRepository.emit('UserSignup', {
notify: !!notify, notify: !!notify,
id: user.id, id: user.id,
tempPassword: user.shouldChangePassword ? userDto.password : undefined, password: userDto.password,
}); });
return mapUserAdmin(user); return mapUserAdmin(user);

View file

@ -249,7 +249,7 @@ export interface IEmailJob {
} }
export interface INotifySignupJob extends IEntityJob { export interface INotifySignupJob extends IEntityJob {
tempPassword?: string; password?: string;
} }
export interface INotifyAlbumInviteJob extends IEntityJob { export interface INotifyAlbumInviteJob extends IEntityJob {

View file

@ -1,5 +1,6 @@
import js from '@eslint/js'; import js from '@eslint/js';
import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat'; import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat';
import prettier from 'eslint-config-prettier';
import eslintPluginCompat from 'eslint-plugin-compat'; import eslintPluginCompat from 'eslint-plugin-compat';
import eslintPluginSvelte from 'eslint-plugin-svelte'; import eslintPluginSvelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import eslintPluginUnicorn from 'eslint-plugin-unicorn';
@ -17,6 +18,7 @@ export default typescriptEslint.config(
...eslintPluginSvelte.configs.recommended, ...eslintPluginSvelte.configs.recommended,
eslintPluginUnicorn.configs.recommended, eslintPluginUnicorn.configs.recommended,
js.configs.recommended, js.configs.recommended,
prettier,
{ {
plugins: { plugins: {
tscompat: tslintPluginCompat, tscompat: tslintPluginCompat,

View file

@ -28,7 +28,7 @@
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8", "@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk", "@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", "@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/core": "^5.11.5",
@ -62,7 +62,6 @@
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.3.0", "@faker-js/faker": "^9.3.0",
"@koddsson/eslint-plugin-tscompat": "^0.2.0", "@koddsson/eslint-plugin-tscompat": "^0.2.0",
@ -82,7 +81,6 @@
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"autoprefixer": "^10.4.17",
"dotenv": "^17.0.0", "dotenv": "^17.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@ -102,7 +100,6 @@
"svelte-check": "^4.1.5", "svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.2.0", "svelte-eslint-parser": "^1.2.0",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tslib": "^2.6.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"vite": "^7.1.2", "vite": "^7.1.2",

View file

@ -169,3 +169,13 @@
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); 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;
}
}

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte'; import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk'; import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
@ -32,7 +33,7 @@
} }
abortController = new AbortController(); abortController = new AbortController();
let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false }); let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false, ...authManager.params });
let markers: MapMarkerResponseDto[] = []; let markers: MapMarkerResponseDto[] = [];
for (const asset of albumInfo.assets) { for (const asset of albumInfo.assets) {

View file

@ -21,7 +21,6 @@
const handleAddTag = async () => { const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] }); const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) { if (success) {
asset = await getAssetInfo({ id: asset.id }); asset = await getAssetInfo({ id: asset.id });
} }

View file

@ -16,21 +16,14 @@
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils'; import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils'; import { getParentPath } from '$lib/utils/tree-utils';
import { import { AssetMediaSize, getAssetInfo, updateAsset, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
AssetMediaSize,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { IconButton } from '@immich/ui';
import { import {
mdiCalendar, mdiCalendar,
@ -61,17 +54,28 @@
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props(); let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
const getDimensions = (exifInfo: ExifResponseDto) => {
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
if (isFlipped(exifInfo.orientation)) {
return { width: height, height: width };
}
return { width, height };
};
let showAssetPath = $state(false); let showAssetPath = $state(false);
let showEditFaces = $state(false); let showEditFaces = $state(false);
let isOwner = $derived($user?.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let previousId: string | undefined = $state(); let previousId: string | undefined = $state();
$effect(() => { $effect(() => {
@ -84,42 +88,6 @@
} }
}); });
let isOwner = $derived($user?.id === asset.ownerId);
const handleNewAsset = async (newAsset: AssetResponseDto) => {
// TODO: check if reloading asset data is necessary
if (newAsset.id && !authManager.isSharedLink) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
}
};
$effect(() => {
handlePromiseError(handleNewAsset(asset));
});
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let people = $state(asset.people || []);
let unassignedFaces = $state(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
const getMegapixel = (width: number, height: number): number | undefined => { const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000); const megapixel = Math.round((height * width) / 1_000_000);
@ -131,10 +99,7 @@
}; };
const handleRefreshPeople = async () => { const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => { asset = await getAssetInfo({ id: asset.id });
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
});
showEditFaces = false; showEditFaces = false;
}; };
@ -525,7 +490,7 @@
<a <a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}" href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank" target="_blank"
class="font-medium text-immich-primary" class="font-medium text-primary underline focus:outline-none"
> >
{$t('open_in_openstreetmap')} {$t('open_in_openstreetmap')}
</a> </a>

View file

@ -197,7 +197,7 @@
<div <div
class={[ class={[
'focus-visible:outline-none flex overflow-hidden', 'focus-visible:outline-none flex overflow-hidden',
disabled ? 'bg-gray-300' : 'bg-primary/30 dark:bg-primary/70', disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
]} ]}
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"

View file

@ -662,6 +662,7 @@
viewport={galleryViewport} viewport={galleryViewport}
{assetInteraction} {assetInteraction}
slidingWindowOffset={viewerHeight} slidingWindowOffset={viewerHeight}
arrowNavigation={false}
/> />
</div> </div>
</section> </section>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@ -12,10 +13,10 @@
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { fly, scale } from 'svelte/transition'; import { fly, scale } from 'svelte/transition';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
let { isUploading } = uploadAssetsStore; let { isUploading } = uploadAssetsStore;
@ -27,11 +28,23 @@
monthGroup: MonthGroup; monthGroup: MonthGroup;
timelineManager: TimelineManager; timelineManager: TimelineManager;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void; onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void; onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void; onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void; onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
} }
let { let {
@ -42,10 +55,12 @@
monthGroup = $bindable(), monthGroup = $bindable(),
assetInteraction, assetInteraction,
timelineManager, timelineManager,
customLayout,
onSelect, onSelect,
onSelectAssets, onSelectAssets,
onSelectAssetCandidates, onSelectAssetCandidates,
onScrollCompensation, onScrollCompensation,
onThumbnailClick,
}: Props = $props(); }: Props = $props();
let isMouseOverGroup = $state(false); let isMouseOverGroup = $state(false);
@ -55,7 +70,7 @@
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150, monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
); );
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const onClick = ( const _onClick = (
timelineManager: TimelineManager, timelineManager: TimelineManager,
assets: TimelineAsset[], assets: TimelineAsset[],
groupTitle: string, groupTitle: string,
@ -202,7 +217,13 @@
{showArchiveIcon} {showArchiveIcon}
{asset} {asset}
{groupIndex} {groupIndex}
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)} onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)} onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))} onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) || selected={assetInteraction.hasSelectedAsset(asset.id) ||
@ -212,6 +233,9 @@
thumbnailWidth={position.width} thumbnailWidth={position.width}
thumbnailHeight={position.height} thumbnailHeight={position.height}
/> />
{#if customLayout}
{@render customLayout(asset)}
{/if}
</div> </div>
<!-- {/if} --> <!-- {/if} -->
{/each} {/each}

View file

@ -13,6 +13,7 @@
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte'; import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@ -65,6 +66,18 @@
onEscape?: () => void; onEscape?: () => void;
children?: Snippet; children?: Snippet;
empty?: Snippet; empty?: Snippet;
customLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
} }
let { let {
@ -84,6 +97,8 @@
onEscape = () => {}, onEscape = () => {},
children, children,
empty, empty,
customLayout,
onThumbnailClick,
}: Props = $props(); }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore; let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
@ -940,6 +955,8 @@
onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets} onSelectAssets={handleSelectAssets}
onScrollCompensation={handleScrollCompensation} onScrollCompensation={handleScrollCompensation}
{customLayout}
{onThumbnailClick}
/> />
</div> </div>
{/if} {/if}

View file

@ -1,113 +0,0 @@
<script lang="ts">
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onDateChange: (year?: number, month?: number, day?: number) => Promise<void>;
onClearFilters?: () => void;
defaultDate?: string;
}
let { onDateChange, onClearFilters, defaultDate }: Props = $props();
let selectedYear = $state<number | undefined>(undefined);
let selectedMonth = $state<number | undefined>(undefined);
let selectedDay = $state<number | undefined>(undefined);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 30 }, (_, i) => currentYear - i);
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: new Date(2000, i).toLocaleString('default', { month: 'long' }),
}));
const dayOptions = $derived.by(() => {
if (!selectedYear || !selectedMonth) {
return [];
}
const daysInMonth = new Date(selectedYear, selectedMonth, 0).getDate();
return Array.from({ length: daysInMonth }, (_, i) => i + 1);
});
if (defaultDate) {
const [year, month, day] = defaultDate.split('-');
selectedYear = Number.parseInt(year);
selectedMonth = Number.parseInt(month);
selectedDay = Number.parseInt(day);
}
const filterAssetsByDate = async () => {
await onDateChange(selectedYear, selectedMonth, selectedDay);
};
const clearFilters = () => {
selectedYear = undefined;
selectedMonth = undefined;
selectedDay = undefined;
if (onClearFilters) {
onClearFilters();
}
};
</script>
<div class="mt-2 mb-2 p-2 rounded-lg">
<div class="flex flex-wrap gap-4 items-end w-136">
<div class="flex-1 min-w-20">
<label for="year-select" class="immich-form-label">
{$t('year')}
</label>
<select
id="year-select"
bind:value={selectedYear}
onchange={filterAssetsByDate}
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value={undefined}>{$t('year')}</option>
{#each yearOptions as year (year)}
<option value={year}>{year}</option>
{/each}
</select>
</div>
<div class="flex-2 min-w-24">
<label for="month-select" class="immich-form-label">
{$t('month')}
</label>
<select
id="month-select"
bind:value={selectedMonth}
onchange={filterAssetsByDate}
disabled={!selectedYear}
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:bg-gray-400"
>
<option value={undefined}>{$t('month')}</option>
{#each monthOptions as month (month.value)}
<option value={month.value}>{month.label}</option>
{/each}
</select>
</div>
<div class="flex-1 min-w-16">
<label for="day-select" class="immich-form-label">
{$t('day')}
</label>
<select
id="day-select"
bind:value={selectedDay}
onchange={filterAssetsByDate}
disabled={!selectedYear || !selectedMonth}
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:bg-gray-400"
>
<option value={undefined}>{$t('day')}</option>
{#each dayOptions as day (day)}
<option value={day}>{day}</option>
{/each}
</select>
</div>
<div class="flex">
<Button size="small" color="secondary" variant="ghost" onclick={clearFilters}>{$t('reset')}</Button>
</div>
</div>
</div>

View file

@ -42,6 +42,7 @@
onReload?: (() => void) | undefined; onReload?: (() => void) | undefined;
pageHeaderOffset?: number; pageHeaderOffset?: number;
slidingWindowOffset?: number; slidingWindowOffset?: number;
arrowNavigation?: boolean;
} }
let { let {
@ -60,6 +61,7 @@
onReload = undefined, onReload = undefined,
slidingWindowOffset = 0, slidingWindowOffset = 0,
pageHeaderOffset = 0, pageHeaderOffset = 0,
arrowNavigation = true,
}: Props = $props(); }: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore; let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
@ -306,8 +308,12 @@
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal }, { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset }, ...(arrowNavigation
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset }, ? [
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
]
: []),
]; ];
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {

View file

@ -2,11 +2,12 @@
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import Portal from '$lib/components/shared-components/portal/portal.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation'; 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 { Button } from '@immich/ui';
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js'; import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
@ -42,32 +43,32 @@
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
}); });
const onNext = () => { const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1; const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) { if (index >= assets.length) {
return Promise.resolve(false); return false;
} }
setAsset(assets[index]); await onViewAsset(assets[index]);
return Promise.resolve(true); return true;
}; };
const onPrevious = () => { const onPrevious = async () => {
const index = getAssetIndex($viewingAsset.id) - 1; const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) { if (index < 0) {
return Promise.resolve(false); return false;
} }
setAsset(assets[index]); await onViewAsset(assets[index]);
return Promise.resolve(true); return true;
}; };
const onRandom = () => { const onRandom = async () => {
if (assets.length <= 0) { if (assets.length <= 0) {
return Promise.resolve(undefined); return;
} }
const index = Math.floor(Math.random() * assets.length); const index = Math.floor(Math.random() * assets.length);
const asset = assets[index]; const asset = assets[index];
setAsset(asset); await onViewAsset(asset);
return Promise.resolve(asset); return { id: asset.id };
}; };
const onSelectAsset = (asset: AssetResponseDto) => { const onSelectAsset = (asset: AssetResponseDto) => {
@ -86,6 +87,12 @@
selectedAssetIds = new SvelteSet(assets.map((asset) => asset.id)); 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 handleResolve = () => {
const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id)); const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
const duplicateAssetIds = assets.map((asset) => asset.id); const duplicateAssetIds = assets.map((asset) => asset.id);
@ -102,9 +109,7 @@
{ shortcut: { key: 'a' }, onShortcut: onSelectAll }, { shortcut: { key: 'a' }, onShortcut: onSelectAll },
{ {
shortcut: { key: 's' }, shortcut: { key: 's' },
onShortcut: () => { onShortcut: () => onViewAsset(assets[0]),
setAsset(assets[0]);
},
}, },
{ shortcut: { key: 'd' }, onShortcut: onSelectNone }, { shortcut: { key: 'd' }, onShortcut: onSelectNone },
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve }, { shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
@ -166,12 +171,7 @@
<div class="flex flex-wrap gap-1 mb-4 place-items-center place-content-center px-4 pt-4"> <div class="flex flex-wrap gap-1 mb-4 place-items-center place-content-center px-4 pt-4">
{#each assets as asset (asset.id)} {#each assets as asset (asset.id)}
<DuplicateAsset <DuplicateAsset {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
{asset}
{onSelectAsset}
isSelected={selectedAssetIds.has(asset.id)}
onViewAsset={(asset) => setAsset(asset)}
/>
{/each} {/each}
</div> </div>
</div> </div>

View file

@ -1,104 +0,0 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
assetInteraction: AssetInteraction;
onSelectAsset: (asset: AssetResponseDto) => void;
onMouseEvent: (asset: AssetResponseDto) => void;
onLocation: (location: { latitude: number; longitude: number }) => void;
}
let { asset, assetInteraction, onSelectAsset, onMouseEvent, onLocation }: Props = $props();
let assetData = $derived(
JSON.stringify(
{
originalFileName: asset.originalFileName,
localDateTime: asset.localDateTime,
make: asset.exifInfo?.make,
model: asset.exifInfo?.model,
gps: {
latitude: asset.exifInfo?.latitude,
longitude: asset.exifInfo?.longitude,
},
location: asset.exifInfo?.city ? `${asset.exifInfo?.country} - ${asset.exifInfo?.city}` : undefined,
},
null,
2,
),
);
let boxWidth = $state(300);
let timelineAsset = $derived(toTimelineAsset(asset));
const hasGps = $derived(!!asset.exifInfo?.latitude && !!asset.exifInfo?.longitude);
</script>
<div
class="w-full aspect-square rounded-xl border-3 transition-colors font-semibold text-xs dark:bg-black bg-gray-200 border-gray-200 dark:border-gray-800"
bind:clientWidth={boxWidth}
title={assetData}
>
<div class="relative w-full h-full overflow-hidden rounded-lg">
<Thumbnail
asset={timelineAsset}
onClick={() => {
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}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{$t('gps')}
</div>
{:else}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-danger text-light">
{$t('gps_missing')}
</div>
{/if}
</div>
<div class="text-center mt-4 px-4 text-sm font-semibold truncate" title={asset.originalFileName}>
<a href={`${AppRoute.PHOTOS}/${asset.id}`} target="_blank" rel="noopener noreferrer">
{asset.originalFileName}
</a>
</div>
<div class="text-center my-3">
<p class="px-4 text-xs font-normal truncate text-dark/75">
{new Date(asset.localDateTime).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
<p class="px-4 text-xs font-normal truncate text-dark/75">
{new Date(asset.localDateTime).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'UTC',
})}
</p>
{#if hasGps}
<p class="text-primary mt-2 text-xs font-normal px-4 text-center truncate">
{asset.exifInfo?.country}
</p>
<p class="text-primary text-xs font-normal px-4 text-center truncate">
{asset.exifInfo?.city}
</p>
{/if}
</div>
</div>

View file

@ -187,6 +187,11 @@ export class MonthGroup {
thumbhash: bucketAssets.thumbhash[i], thumbhash: bucketAssets.thumbhash[i],
people: null, // People are not included in the bucket assets 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); this.addTimelineAsset(timelineAsset, addContext);
} }

View file

@ -31,6 +31,8 @@ export type TimelineAsset = {
city: string | null; city: string | null;
country: string | null; country: string | null;
people: string[] | null; people: string[] | null;
latitude?: number | null;
longitude?: number | null;
}; };
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };

View file

@ -1,11 +1,16 @@
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk'; import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
class UploadManager { class UploadManager {
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] }); mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
constructor() { 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() { async #loadExtensions() {

View file

@ -34,6 +34,7 @@ import {
type AssetResponseDto, type AssetResponseDto,
type AssetTypeEnum, type AssetTypeEnum,
type DownloadInfoDto, type DownloadInfoDto,
type ExifResponseDto,
type StackResponseDto, type StackResponseDto,
type UserPreferencesResponseDto, type UserPreferencesResponseDto,
type UserResponseDto, type UserResponseDto,
@ -328,6 +329,15 @@ export function isFlipped(orientation?: string | null) {
return value && (isRotated270CW(value) || isRotated90CW(value)); 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 { export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string {
const size = asset.exifInfo?.fileSizeInByte || 0; const size = asset.exifInfo?.fileSizeInByte || 0;
return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data'; return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data';

Some files were not shown because too many files have changed in this diff Show more