mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge remote-tracking branch 'origin/main' into feat/gps-utility-improvements-21894
This commit is contained in:
commit
5a073e367c
433 changed files with 14465 additions and 4565 deletions
|
|
@ -5,8 +5,7 @@
|
||||||
"immich-server",
|
"immich-server",
|
||||||
"redis",
|
"redis",
|
||||||
"database",
|
"database",
|
||||||
"immich-machine-learning",
|
"immich-machine-learning"
|
||||||
"init"
|
|
||||||
],
|
],
|
||||||
"dockerComposeFile": [
|
"dockerComposeFile": [
|
||||||
"../docker/docker-compose.dev.yml",
|
"../docker/docker-compose.dev.yml",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ services:
|
||||||
- server_node_modules:/workspaces/immich/server/node_modules
|
- server_node_modules:/workspaces/immich/server/node_modules
|
||||||
- web_node_modules:/workspaces/immich/web/node_modules
|
- web_node_modules:/workspaces/immich/web/node_modules
|
||||||
- ${UPLOAD_LOCATION}/photos:/data
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
||||||
database:
|
database:
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ services:
|
||||||
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ..:/workspaces/immich
|
- ..:/workspaces/immich
|
||||||
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||||
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
- server-node_modules:/usr/src/app/server/node_modules
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
|
@ -24,9 +23,6 @@ services:
|
||||||
- coverage:/usr/src/app/web/coverage
|
- coverage:/usr/src/app/web/coverage
|
||||||
immich-web:
|
immich-web:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
init:
|
|
||||||
env_file: !reset []
|
|
||||||
command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-0}:${GID:-0} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-0}:${GID:-0} "$$path" || true; done'
|
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
database:
|
database:
|
||||||
|
|
@ -42,7 +38,5 @@ services:
|
||||||
redis:
|
redis:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
volumes:
|
volumes:
|
||||||
# Node modules for each service to avoid conflicts and ensure consistent dependencies
|
upload-devcontainer-volume:
|
||||||
upload1-devcontainer-volume:
|
|
||||||
upload2-devcontainer-volume:
|
|
||||||
postgres-devcontainer-volume:
|
postgres-devcontainer-volume:
|
||||||
|
|
|
||||||
20
.github/workflows/build-mobile.yml
vendored
20
.github/workflows/build-mobile.yml
vendored
|
|
@ -32,24 +32,18 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
mobile:
|
mobile:
|
||||||
- 'mobile/**'
|
- 'mobile/**'
|
||||||
workflow:
|
force-filters: |
|
||||||
- '.github/workflows/build-mobile.yml'
|
- '.github/workflows/build-mobile.yml'
|
||||||
- name: Check if we should force jobs to run
|
force-events: 'workflow_call,workflow_dispatch'
|
||||||
id: should_force
|
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
build-sign-android:
|
build-sign-android:
|
||||||
name: Build and sign Android
|
name: Build and sign Android
|
||||||
|
|
@ -57,7 +51,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
# Skip when PR from a fork
|
# Skip when PR from a fork
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: mich
|
runs-on: mich
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
|
|
@ -35,7 +35,7 @@ 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: ghcr.io/immich-app/mdq:main@sha256:1669c75a5542333ff6b03c13d5fd259ea8d798188b84d5d99093d62e4542eb05
|
image: ghcr.io/immich-app/mdq:main@sha256:d8ae47cf2e6cf4e2559bd57a60b73674fe44f897cba2c2bddff2987a05be10a4
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -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@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||||
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@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||||
|
|
||||||
# ℹ️ 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@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|
|
||||||
27
.github/workflows/docker.yml
vendored
27
.github/workflows/docker.yml
vendored
|
|
@ -20,15 +20,11 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
server:
|
server:
|
||||||
|
|
@ -38,14 +34,11 @@ jobs:
|
||||||
- 'i18n/**'
|
- 'i18n/**'
|
||||||
machine-learning:
|
machine-learning:
|
||||||
- 'machine-learning/**'
|
- 'machine-learning/**'
|
||||||
workflow:
|
force-filters: |
|
||||||
- '.github/workflows/docker.yml'
|
- '.github/workflows/docker.yml'
|
||||||
- '.github/workflows/multi-runner-build.yml'
|
- '.github/workflows/multi-runner-build.yml'
|
||||||
- '.github/actions/image-build'
|
- '.github/actions/image-build'
|
||||||
|
force-events: 'workflow_dispatch,release'
|
||||||
- name: Check if we should force jobs to run
|
|
||||||
id: should_force
|
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
retag_ml:
|
retag_ml:
|
||||||
name: Re-Tag ML
|
name: Re-Tag ML
|
||||||
|
|
@ -53,7 +46,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -82,7 +75,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -108,7 +101,7 @@ jobs:
|
||||||
machine-learning:
|
machine-learning:
|
||||||
name: Build and Push ML
|
name: Build and Push ML
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -153,7 +146,7 @@ jobs:
|
||||||
server:
|
server:
|
||||||
name: Build and Push Server
|
name: Build and Push Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
22
.github/workflows/docs-build.yml
vendored
22
.github/workflows/docs-build.yml
vendored
|
|
@ -18,32 +18,28 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.found_paths.outputs.open-api == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
docs:
|
docs:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
workflow:
|
|
||||||
- '.github/workflows/docs-build.yml'
|
|
||||||
open-api:
|
open-api:
|
||||||
- 'open-api/immich-openapi-specs.json'
|
- 'open-api/immich-openapi-specs.json'
|
||||||
- name: Check if we should force jobs to run
|
force-filters: |
|
||||||
id: should_force
|
- '.github/workflows/docs-build.yml'
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT"
|
force-events: 'release'
|
||||||
|
force-branches: 'main'
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Docs Build
|
name: Docs Build
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).docs == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|
|
||||||
3
.github/workflows/fix-format.yml
vendored
3
.github/workflows/fix-format.yml
vendored
|
|
@ -28,6 +28,9 @@ jobs:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/prepare-release.yml
vendored
2
.github/workflows/prepare-release.yml
vendored
|
|
@ -119,7 +119,7 @@ jobs:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ env.IMMICH_VERSION }}
|
tag_name: ${{ env.IMMICH_VERSION }}
|
||||||
|
|
|
||||||
24
.github/workflows/static_analysis.yml
vendored
24
.github/workflows/static_analysis.yml
vendored
|
|
@ -17,28 +17,23 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
mobile:
|
mobile:
|
||||||
- 'mobile/**'
|
- 'mobile/**'
|
||||||
workflow:
|
force-filters: |
|
||||||
- '.github/workflows/static_analysis.yml'
|
- '.github/workflows/static_analysis.yml'
|
||||||
- name: Check if we should force jobs to run
|
force-events: 'workflow_dispatch,release'
|
||||||
id: should_force
|
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
mobile-dart-analyze:
|
mobile-dart-analyze:
|
||||||
name: Run Dart Code Analysis
|
name: Run Dart Code Analysis
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -100,8 +95,9 @@ jobs:
|
||||||
- name: Run dart format
|
- name: Run dart format
|
||||||
run: make format
|
run: make format
|
||||||
|
|
||||||
- name: Run dart custom_lint
|
# TODO: Re-enable after upgrading custom_lint
|
||||||
run: dart run custom_lint
|
# - name: Run dart custom_lint
|
||||||
|
# run: dart run custom_lint
|
||||||
|
|
||||||
# TODO: Use https://github.com/CQLabs/dcm-action
|
# TODO: Use https://github.com/CQLabs/dcm-action
|
||||||
- name: Run DCM
|
- name: Run DCM
|
||||||
|
|
|
||||||
57
.github/workflows/test.yml
vendored
57
.github/workflows/test.yml
vendored
|
|
@ -14,23 +14,11 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_e2e: ${{ steps.found_paths.outputs.e2e == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_mobile: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_e2e_web: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_e2e_server_cli: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.server == 'true' || steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
|
|
||||||
should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
i18n:
|
i18n:
|
||||||
|
|
@ -50,17 +38,16 @@ jobs:
|
||||||
- 'mobile/**'
|
- 'mobile/**'
|
||||||
machine-learning:
|
machine-learning:
|
||||||
- 'machine-learning/**'
|
- 'machine-learning/**'
|
||||||
workflow:
|
|
||||||
- '.github/workflows/test.yml'
|
|
||||||
.github:
|
.github:
|
||||||
- '.github/**'
|
- '.github/**'
|
||||||
- name: Check if we should force jobs to run
|
force-filters: |
|
||||||
id: should_force
|
- '.github/workflows/test.yml'
|
||||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
|
force-events: 'workflow_dispatch'
|
||||||
|
|
||||||
server-unit-tests:
|
server-unit-tests:
|
||||||
name: Test & Lint Server
|
name: Test & Lint Server
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -97,7 +84,7 @@ jobs:
|
||||||
cli-unit-tests:
|
cli-unit-tests:
|
||||||
name: Unit Test CLI
|
name: Unit Test CLI
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -137,7 +124,7 @@ jobs:
|
||||||
cli-unit-tests-win:
|
cli-unit-tests-win:
|
||||||
name: Unit Test CLI (Windows)
|
name: Unit Test CLI (Windows)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -172,7 +159,7 @@ jobs:
|
||||||
web-lint:
|
web-lint:
|
||||||
name: Lint Web
|
name: Lint Web
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||||
runs-on: mich
|
runs-on: mich
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -209,7 +196,7 @@ jobs:
|
||||||
web-unit-tests:
|
web-unit-tests:
|
||||||
name: Test Web
|
name: Test Web
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -243,7 +230,7 @@ jobs:
|
||||||
i18n-tests:
|
i18n-tests:
|
||||||
name: Test i18n
|
name: Test i18n
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -281,7 +268,7 @@ jobs:
|
||||||
e2e-tests-lint:
|
e2e-tests-lint:
|
||||||
name: End-to-End Lint
|
name: End-to-End Lint
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -320,7 +307,7 @@ jobs:
|
||||||
server-medium-tests:
|
server-medium-tests:
|
||||||
name: Medium Tests (Server)
|
name: Medium Tests (Server)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -348,7 +335,7 @@ jobs:
|
||||||
e2e-tests-server-cli:
|
e2e-tests-server-cli:
|
||||||
name: End-to-End Tests (Server & CLI)
|
name: End-to-End Tests (Server & CLI)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -396,7 +383,7 @@ jobs:
|
||||||
e2e-tests-web:
|
e2e-tests-web:
|
||||||
name: End-to-End Tests (Web)
|
name: End-to-End Tests (Web)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -449,7 +436,7 @@ jobs:
|
||||||
mobile-unit-tests:
|
mobile-unit-tests:
|
||||||
name: Unit Test Mobile
|
name: Unit Test Mobile
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -471,7 +458,7 @@ jobs:
|
||||||
ml-unit-tests:
|
ml-unit-tests:
|
||||||
name: Unit Test ML
|
name: Unit Test ML
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -507,7 +494,7 @@ jobs:
|
||||||
github-files-formatting:
|
github-files-formatting:
|
||||||
name: .github Files Formatting
|
name: .github Files Formatting
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs['should_run_.github'] == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run)['.github'] == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -594,7 +581,7 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:4f7ee144d4738ad02f6d9376defed7a767b748d185d47eba241578c26a63064b
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:da52bbead5d818adaa8077c8dcdaad0aaf93038c31ad8348b51f9f0ec1310a4d
|
||||||
env:
|
env:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
|
|
|
||||||
15
.github/workflows/weblate-lock.yml
vendored
15
.github/workflows/weblate-lock.yml
vendored
|
|
@ -21,25 +21,24 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.found_paths.outputs.i18n == 'true' }}
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Check what should run
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
id: check
|
||||||
with:
|
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||||
persist-credentials: false
|
|
||||||
- id: found_paths
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
i18n:
|
i18n:
|
||||||
- 'i18n/!(en)**\.json'
|
- 'i18n/!(en)**\.json'
|
||||||
|
exclude-branches: 'chore/translations'
|
||||||
|
skip-force-logic: 'true'
|
||||||
|
|
||||||
enforce-lock:
|
enforce-lock:
|
||||||
name: Check Weblate Lock
|
name: Check Weblate Lock
|
||||||
needs: [pre-job]
|
needs: [pre-job]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions: {}
|
permissions: {}
|
||||||
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
|
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
|
||||||
steps:
|
steps:
|
||||||
- name: Bot review status
|
- name: Bot review status
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,6 +18,7 @@ mobile/libisar.dylib
|
||||||
mobile/openapi/test
|
mobile/openapi/test
|
||||||
mobile/openapi/doc
|
mobile/openapi/doc
|
||||||
mobile/openapi/.openapi-generator/FILES
|
mobile/openapi/.openapi-generator/FILES
|
||||||
|
mobile/ios/build
|
||||||
|
|
||||||
open-api/typescript-sdk/build
|
open-api/typescript-sdk/build
|
||||||
mobile/android/fastlane/report.xml
|
mobile/android/fastlane/report.xml
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@
|
||||||
/web/ @danieldietzler
|
/web/ @danieldietzler
|
||||||
/machine-learning/ @mertalev
|
/machine-learning/ @mertalev
|
||||||
/e2e/ @danieldietzler
|
/e2e/ @danieldietzler
|
||||||
|
/mobile/ @shenlong-tanwen
|
||||||
|
|
|
||||||
40
Makefile
40
Makefile
|
|
@ -1,13 +1,13 @@
|
||||||
dev: prepare-volumes
|
dev:
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
dev-down:
|
dev-down:
|
||||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||||
|
|
||||||
dev-update: prepare-volumes
|
dev-update:
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
dev-scale: prepare-volumes
|
dev-scale:
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||||
|
|
||||||
dev-docs:
|
dev-docs:
|
||||||
|
|
@ -33,16 +33,16 @@ prod-scale:
|
||||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||||
|
|
||||||
.PHONY: open-api
|
.PHONY: open-api
|
||||||
open-api: prepare-volumes
|
open-api:
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh
|
cd ./open-api && bash ./bin/generate-open-api.sh
|
||||||
|
|
||||||
open-api-dart: prepare-volumes
|
open-api-dart:
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh dart
|
cd ./open-api && bash ./bin/generate-open-api.sh dart
|
||||||
|
|
||||||
open-api-typescript: prepare-volumes
|
open-api-typescript:
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
||||||
|
|
||||||
sql: prepare-volumes
|
sql:
|
||||||
pnpm --filter immich run sync:sql
|
pnpm --filter immich run sync:sql
|
||||||
|
|
||||||
attach-server:
|
attach-server:
|
||||||
|
|
@ -68,32 +68,6 @@ VOLUME_DIRS = \
|
||||||
# Include .env file if it exists
|
# Include .env file if it exists
|
||||||
-include docker/.env
|
-include docker/.env
|
||||||
|
|
||||||
# Helper function to chown, on error suggest remediation and exit
|
|
||||||
define safe_chown
|
|
||||||
CURRENT_OWNER=$$(stat -c '%u:%g' "$(1)" 2>/dev/null || echo "none"); \
|
|
||||||
DESIRED_OWNER="$(or $(UID),0):$(or $(GID),0)"; \
|
|
||||||
if [ "$$CURRENT_OWNER" != "$$DESIRED_OWNER" ] && ! chown -v $(2) $$DESIRED_OWNER "$(1)" 2>/dev/null; then \
|
|
||||||
echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
|
|
||||||
exit 1; \
|
|
||||||
fi;
|
|
||||||
endef
|
|
||||||
# create empty directories and chown
|
|
||||||
prepare-volumes:
|
|
||||||
@$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);)
|
|
||||||
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
|
|
||||||
ifneq ($(UPLOAD_LOCATION),)
|
|
||||||
ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
|
|
||||||
@mkdir -p "docker/$(UPLOAD_LOCATION)/photos/upload"
|
|
||||||
@$(call safe_chown,docker/$(UPLOAD_LOCATION),)
|
|
||||||
@$(call safe_chown,docker/$(UPLOAD_LOCATION)/photos,-R)
|
|
||||||
else
|
|
||||||
@mkdir -p "$(UPLOAD_LOCATION)/photos/upload"
|
|
||||||
@$(call safe_chown,$(UPLOAD_LOCATION),)
|
|
||||||
@$(call safe_chown,$(UPLOAD_LOCATION)/photos,-R)
|
|
||||||
endif
|
|
||||||
endif
|
|
||||||
|
|
||||||
|
|
||||||
MODULES = e2e server web cli sdk docs .github
|
MODULES = e2e server web cli sdk docs .github
|
||||||
|
|
||||||
# directory to package name mapping function
|
# directory to package name mapping function
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^22.18.0",
|
"@types/node": "^22.18.1",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,37 @@
|
||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||||
version = "4.52.3"
|
version = "4.52.5"
|
||||||
constraints = "4.52.3"
|
constraints = "4.52.5"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:3jU62KY4Oj3xzMwkTQWon1nlIvFkgTCqI93IzUGaa0c=",
|
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
|
||||||
"h1:BWimtYXrvbzbbuoVcyobjQnXjjOb9X69JFTw+GuPxfk=",
|
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
|
||||||
"h1:C/KvLEm8dVQ6zG2X4asLDtmw2JW/xu7E8MddtaXniO0=",
|
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
|
||||||
"h1:Doo0xcLFf+CnfDWjsA7G1NvSLURuwcgyVy8k0NF1gJA=",
|
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
|
||||||
"h1:Gc3FGDtR8lUWsi9VImnnE5/USDXiIwYsv4Hbl+d2lwY=",
|
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
|
||||||
"h1:HsDY6s1gup5fW9TeuTUy85QMIld1nDOUFlwsfxIq1ig=",
|
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
|
||||||
"h1:MnHkB56E4b/kT6WZigsZJnB5rgnCfDVbrLBNxIsEXPY=",
|
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
|
||||||
"h1:O/FUQEqhtknJNdsaMbIBi2pLWBds2VvN5FsTVVntzb0=",
|
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
|
||||||
"h1:OKQBynkp0J5DIf5FOl/NR3S2rvh89pY+t5wevYxdTJs=",
|
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
|
||||||
"h1:On+vPsYV8U/J/8wFZPXjeAgNJqFFQj42vNOKuNKURkY=",
|
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
|
||||||
"h1:SPkrMRJahxK0uum7FnUugbGN/JepHMH8M71DBtYrvG0=",
|
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
|
||||||
"h1:bEh1ASPMiin3F36+hTfjMQTBnuDl2DzjzSCdova3JEM=",
|
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
|
||||||
"h1:dtIK+x5Q1sh5SMPaHBHXhL9XDIqbRW0EBmVZ+KHQB8E=",
|
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
|
||||||
"h1:kZcwWfODMWWyauZ66oaO/X+xXkqBtrbYwfUFEtspwEc=",
|
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
|
||||||
"zh:53946fce4a631f1d98c61550821c88edede9169dfe5cc254e09a2ab207f76b3f",
|
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
|
||||||
"zh:61654a21f1dd4331492d4ef77e9ebff066bc01e1281f92b925e5697c9138d681",
|
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
|
||||||
"zh:6a54e9d129b276f052a2f1b73ad0b8735fe6a7403c6a8f6aa111e525eeefaf35",
|
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
|
||||||
"zh:7692374e655c346a630b5a7cd776c5e0b2388900dcd7ab69a3af85d0c31c6c43",
|
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
|
||||||
|
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
|
||||||
|
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
|
||||||
|
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
|
||||||
|
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
|
||||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||||
"zh:8fe5b792a4d2b1c3a0e573649642962494faa00299baa6aaf813b9a43203dc02",
|
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||||
"zh:a0f403a4862df90f09de65c6e939d6cfd069a8dda2dd33f82948bf6f5f1124ef",
|
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||||
"zh:a25dc3eb60777b600f8f125d321fe7c50b811c5302b58e9a727ceb749a04e35d",
|
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||||
"zh:a2f2ac7dc703c69d2e8c67c9cb5620b5348cb4fd6b98515fbe3f478517b56602",
|
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||||
"zh:d452e7bd24445ee14166470cf50f3aca566d46cab5f26f1c5c988c0f3106b697",
|
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||||
"zh:e10a52b0294735659eb3f0821ad2006ec097918efe58d31d37a5e3c47efef5f6",
|
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||||
"zh:e28dd0954cef9f05adf4d4b440d6f134f605344dfa56307181996675e6550af2",
|
|
||||||
"zh:f1e3b2f43a472280442f01ba71a3c06c9167432e553381132ea5c4a77e0b6dd5",
|
|
||||||
"zh:f71fd63718d38fd43829861e91fe79e16d7b4c7c3d508ae3d077368d89b8e5a0",
|
|
||||||
"zh:faf8d3da4b819c4ae8e565d2b1a684c6a948a086cb299189a5e7b30b2178409d",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ terraform {
|
||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.52.3"
|
version = "4.52.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,37 @@
|
||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||||
version = "4.52.3"
|
version = "4.52.5"
|
||||||
constraints = "4.52.3"
|
constraints = "4.52.5"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:3jU62KY4Oj3xzMwkTQWon1nlIvFkgTCqI93IzUGaa0c=",
|
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
|
||||||
"h1:BWimtYXrvbzbbuoVcyobjQnXjjOb9X69JFTw+GuPxfk=",
|
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
|
||||||
"h1:C/KvLEm8dVQ6zG2X4asLDtmw2JW/xu7E8MddtaXniO0=",
|
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
|
||||||
"h1:Doo0xcLFf+CnfDWjsA7G1NvSLURuwcgyVy8k0NF1gJA=",
|
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
|
||||||
"h1:Gc3FGDtR8lUWsi9VImnnE5/USDXiIwYsv4Hbl+d2lwY=",
|
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
|
||||||
"h1:HsDY6s1gup5fW9TeuTUy85QMIld1nDOUFlwsfxIq1ig=",
|
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
|
||||||
"h1:MnHkB56E4b/kT6WZigsZJnB5rgnCfDVbrLBNxIsEXPY=",
|
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
|
||||||
"h1:O/FUQEqhtknJNdsaMbIBi2pLWBds2VvN5FsTVVntzb0=",
|
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
|
||||||
"h1:OKQBynkp0J5DIf5FOl/NR3S2rvh89pY+t5wevYxdTJs=",
|
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
|
||||||
"h1:On+vPsYV8U/J/8wFZPXjeAgNJqFFQj42vNOKuNKURkY=",
|
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
|
||||||
"h1:SPkrMRJahxK0uum7FnUugbGN/JepHMH8M71DBtYrvG0=",
|
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
|
||||||
"h1:bEh1ASPMiin3F36+hTfjMQTBnuDl2DzjzSCdova3JEM=",
|
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
|
||||||
"h1:dtIK+x5Q1sh5SMPaHBHXhL9XDIqbRW0EBmVZ+KHQB8E=",
|
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
|
||||||
"h1:kZcwWfODMWWyauZ66oaO/X+xXkqBtrbYwfUFEtspwEc=",
|
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
|
||||||
"zh:53946fce4a631f1d98c61550821c88edede9169dfe5cc254e09a2ab207f76b3f",
|
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
|
||||||
"zh:61654a21f1dd4331492d4ef77e9ebff066bc01e1281f92b925e5697c9138d681",
|
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
|
||||||
"zh:6a54e9d129b276f052a2f1b73ad0b8735fe6a7403c6a8f6aa111e525eeefaf35",
|
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
|
||||||
"zh:7692374e655c346a630b5a7cd776c5e0b2388900dcd7ab69a3af85d0c31c6c43",
|
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
|
||||||
|
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
|
||||||
|
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
|
||||||
|
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
|
||||||
|
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
|
||||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||||
"zh:8fe5b792a4d2b1c3a0e573649642962494faa00299baa6aaf813b9a43203dc02",
|
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||||
"zh:a0f403a4862df90f09de65c6e939d6cfd069a8dda2dd33f82948bf6f5f1124ef",
|
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||||
"zh:a25dc3eb60777b600f8f125d321fe7c50b811c5302b58e9a727ceb749a04e35d",
|
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||||
"zh:a2f2ac7dc703c69d2e8c67c9cb5620b5348cb4fd6b98515fbe3f478517b56602",
|
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||||
"zh:d452e7bd24445ee14166470cf50f3aca566d46cab5f26f1c5c988c0f3106b697",
|
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||||
"zh:e10a52b0294735659eb3f0821ad2006ec097918efe58d31d37a5e3c47efef5f6",
|
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||||
"zh:e28dd0954cef9f05adf4d4b440d6f134f605344dfa56307181996675e6550af2",
|
|
||||||
"zh:f1e3b2f43a472280442f01ba71a3c06c9167432e553381132ea5c4a77e0b6dd5",
|
|
||||||
"zh:f71fd63718d38fd43829861e91fe79e16d7b4c7c3d508ae3d077368d89b8e5a0",
|
|
||||||
"zh:faf8d3da4b819c4ae8e565d2b1a684c6a948a086cb299189a5e7b30b2178409d",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ terraform {
|
||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.52.3"
|
version = "4.52.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,14 @@ services:
|
||||||
# extends:
|
# extends:
|
||||||
# file: hwaccel.transcoding.yml
|
# file: hwaccel.transcoding.yml
|
||||||
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
||||||
user: '${UID:-0}:${GID:-0}'
|
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile.dev
|
||||||
target: dev
|
target: dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/usr/src/app
|
- ..:/usr/src/app
|
||||||
- ${UPLOAD_LOCATION}/photos:/data
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
- server-node_modules:/usr/src/app/server/node_modules
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
|
@ -72,20 +70,15 @@ services:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
database:
|
database:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
immich-web:
|
immich-web:
|
||||||
container_name: immich_web
|
container_name: immich_web
|
||||||
image: immich-web-dev:latest
|
image: immich-web-dev:latest
|
||||||
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
|
|
||||||
# user: 0:0
|
|
||||||
user: '${UID:-0}:${GID:-0}'
|
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile.dev
|
||||||
target: dev
|
target: dev
|
||||||
command: ['immich-web']
|
command: ['immich-web']
|
||||||
env_file:
|
env_file:
|
||||||
|
|
@ -114,8 +107,6 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
immich-server:
|
immich-server:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
|
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
container_name: immich_machine_learning
|
container_name: immich_machine_learning
|
||||||
|
|
@ -149,7 +140,7 @@ services:
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -183,25 +174,6 @@ services:
|
||||||
# volumes:
|
# volumes:
|
||||||
# - grafana-data:/var/lib/grafana
|
# - grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
init:
|
|
||||||
container_name: init
|
|
||||||
image: busybox@sha256:ab33eacc8251e3807b85bb6dba570e4698c3998eca6f0fc2ccb60575a563ea74
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
user: 0:0
|
|
||||||
command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-0}:${GID:-0} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-0}:${GID:-0} "$$path" || true; done'
|
|
||||||
volumes:
|
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
|
||||||
- server-node_modules:/usr/src/app/server/node_modules
|
|
||||||
- web-node_modules:/usr/src/app/web/node_modules
|
|
||||||
- github-node_modules:/usr/src/app/.github/node_modules
|
|
||||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
|
||||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
|
||||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
|
||||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
|
||||||
- app-node_modules:/usr/src/app/node_modules
|
|
||||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
|
||||||
- coverage:/usr/src/app/web/coverage
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ services:
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ services:
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,6 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||||
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
||||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
||||||
| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
|
|
||||||
| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
|
|
||||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
|
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ const guides: CommunityGuidesProps[] = [
|
||||||
description: `synchronize folders in imported library with albums having the folders name.`,
|
description: `synchronize folders in imported library with albums having the folders name.`,
|
||||||
url: 'https://github.com/immich-app/immich/discussions/3382',
|
url: 'https://github.com/immich-app/immich/discussions/3382',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Immich Podman Quadlets Handbook',
|
||||||
|
description:
|
||||||
|
'A rewrite of the original Immich Docker Compose file using Podman Quadlets, with a set of extra guides in the repository’s wiki.',
|
||||||
|
url: 'https://github.com/linux-universe/immich-podman-quadlets/blob/main/README.md',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Podman/Quadlets Install',
|
title: 'Podman/Quadlets Install',
|
||||||
description: 'Documentation for simple podman setup using quadlets.',
|
description: 'Documentation for simple podman setup using quadlets.',
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,16 @@ const projects: CommunityProjectProps[] = [
|
||||||
description: 'A tiny, zero-login web app for collecting photos/videos from anyone into your Immich server.',
|
description: 'A tiny, zero-login web app for collecting photos/videos from anyone into your Immich server.',
|
||||||
url: 'https://github.com/Nasogaa/immich-drop',
|
url: 'https://github.com/Nasogaa/immich-drop',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Immich Birthday Sync',
|
||||||
|
description: 'Bulk-upload and -download birthdays, with CardDAV sync support',
|
||||||
|
url: 'https://github.com/sid3windr/immich-birthday',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Immich Stack',
|
||||||
|
description: 'Auto-stack photos with identical filenames and differing extensions (i.e. JPG+RAW)',
|
||||||
|
url: 'https://github.com/sid3windr/immich-stack',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
|
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,17 @@
|
||||||
|
|
||||||
## TypeORM Upgrade
|
## TypeORM Upgrade
|
||||||
|
|
||||||
In order to update to Immich to `v1.137.0` (or above), the application must be started at least once on a version in the range between `1.132.0` and `1.136.0`. Doing so will complete database schema upgrades that are required for `v1.137.0` (and above). After Immich has successfully updated to a version in this range, you can now attempt to update to v1.137.0 (or above). We recommend users upgrade to `1.132.0` since it does not have any other breaking changes.
|
If you encountered "Migrations failed: Error: Invalid upgrade path" then perform an intermediate upgrade to `v1.132.3` first.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
We recommend users upgrade to `v1.132.3` since it does not have any breaking changes or bugs on this upgrade path.
|
||||||
|
:::
|
||||||
|
|
||||||
|
In order to update to Immich `v1.137.0` or above, the application must be started at least once on a version in the range between `1.132.0` and `1.136.0`. Doing so will complete database schema upgrades that are required for `v1.137.0` (and above). After Immich has successfully updated to a version in this range, you can now attempt to update to `v1.137.0` (or above).
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
Avoid `v1.136.0` if upgrading from `v1.131.0` (or earlier) due to a bug blocking this upgrade in some installations.
|
||||||
|
:::
|
||||||
|
|
||||||
## Inconsistent Media Location
|
## Inconsistent Media Location
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ services:
|
||||||
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:11ced39d65a92a54d12890ced6a26cc2003f92697d6f0d4d944b98459dba7138
|
||||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.18.0",
|
"@types/node": "^22.18.1",
|
||||||
"@types/oidc-provider": "^9.0.0",
|
"@types/oidc-provider": "^9.0.0",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
|
|
|
||||||
39
i18n/en.json
39
i18n/en.json
|
|
@ -123,6 +123,13 @@
|
||||||
"logging_enable_description": "Enable logging",
|
"logging_enable_description": "Enable logging",
|
||||||
"logging_level_description": "When enabled, what log level to use.",
|
"logging_level_description": "When enabled, what log level to use.",
|
||||||
"logging_settings": "Logging",
|
"logging_settings": "Logging",
|
||||||
|
"machine_learning_availability_checks": "Availability checks",
|
||||||
|
"machine_learning_availability_checks_description": "Automatically detect and prefer available machine learning servers",
|
||||||
|
"machine_learning_availability_checks_enabled": "Enable availability checks",
|
||||||
|
"machine_learning_availability_checks_interval": "Check interval",
|
||||||
|
"machine_learning_availability_checks_interval_description": "Interval in milliseconds between availability checks",
|
||||||
|
"machine_learning_availability_checks_timeout": "Request timeout",
|
||||||
|
"machine_learning_availability_checks_timeout_description": "Timeout in milliseconds for availability checks",
|
||||||
"machine_learning_clip_model": "CLIP model",
|
"machine_learning_clip_model": "CLIP model",
|
||||||
"machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
|
"machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
|
||||||
"machine_learning_duplicate_detection": "Duplicate Detection",
|
"machine_learning_duplicate_detection": "Duplicate Detection",
|
||||||
|
|
@ -387,8 +394,6 @@
|
||||||
"admin_password": "Admin Password",
|
"admin_password": "Admin Password",
|
||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced",
|
||||||
"advanced_settings_beta_timeline_subtitle": "Try the new app experience",
|
|
||||||
"advanced_settings_beta_timeline_title": "Beta Timeline",
|
|
||||||
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
|
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
|
||||||
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
|
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
|
||||||
"advanced_settings_log_level_title": "Log level: {level}",
|
"advanced_settings_log_level_title": "Log level: {level}",
|
||||||
|
|
@ -425,6 +430,7 @@
|
||||||
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
|
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
|
||||||
"album_search_not_found": "No albums found matching your search",
|
"album_search_not_found": "No albums found matching your search",
|
||||||
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
|
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
|
||||||
|
"album_summary": "Album summary",
|
||||||
"album_updated": "Album updated",
|
"album_updated": "Album updated",
|
||||||
"album_updated_setting_description": "Receive an email notification when a shared album has new assets",
|
"album_updated_setting_description": "Receive an email notification when a shared album has new assets",
|
||||||
"album_user_left": "Left {album}",
|
"album_user_left": "Left {album}",
|
||||||
|
|
@ -496,6 +502,8 @@
|
||||||
"asset_restored_successfully": "Asset restored successfully",
|
"asset_restored_successfully": "Asset restored successfully",
|
||||||
"asset_skipped": "Skipped",
|
"asset_skipped": "Skipped",
|
||||||
"asset_skipped_in_trash": "In trash",
|
"asset_skipped_in_trash": "In trash",
|
||||||
|
"asset_trashed": "Asset trashed",
|
||||||
|
"asset_troubleshoot": "Asset Troubleshoot",
|
||||||
"asset_uploaded": "Uploaded",
|
"asset_uploaded": "Uploaded",
|
||||||
"asset_uploading": "Uploading…",
|
"asset_uploading": "Uploading…",
|
||||||
"asset_viewer_settings_subtitle": "Manage your gallery viewer settings",
|
"asset_viewer_settings_subtitle": "Manage your gallery viewer settings",
|
||||||
|
|
@ -529,8 +537,10 @@
|
||||||
"autoplay_slideshow": "Autoplay slideshow",
|
"autoplay_slideshow": "Autoplay slideshow",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"back_close_deselect": "Back, close, or deselect",
|
"back_close_deselect": "Back, close, or deselect",
|
||||||
|
"background_backup_running_error": "Background backup is currently running, cannot start manual backup",
|
||||||
"background_location_permission": "Background location permission",
|
"background_location_permission": "Background location permission",
|
||||||
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
|
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
|
||||||
|
"background_options": "Background Options",
|
||||||
"backup": "Backup",
|
"backup": "Backup",
|
||||||
"backup_album_selection_page_albums_device": "Albums on device ({count})",
|
"backup_album_selection_page_albums_device": "Albums on device ({count})",
|
||||||
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
|
||||||
|
|
@ -538,6 +548,7 @@
|
||||||
"backup_album_selection_page_select_albums": "Select albums",
|
"backup_album_selection_page_select_albums": "Select albums",
|
||||||
"backup_album_selection_page_selection_info": "Selection Info",
|
"backup_album_selection_page_selection_info": "Selection Info",
|
||||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||||
|
"backup_albums_sync": "Backup albums synchronization",
|
||||||
"backup_all": "All",
|
"backup_all": "All",
|
||||||
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
||||||
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
|
||||||
|
|
@ -654,6 +665,8 @@
|
||||||
"change_pin_code": "Change PIN code",
|
"change_pin_code": "Change PIN code",
|
||||||
"change_your_password": "Change your password",
|
"change_your_password": "Change your password",
|
||||||
"changed_visibility_successfully": "Changed visibility successfully",
|
"changed_visibility_successfully": "Changed visibility successfully",
|
||||||
|
"charging": "Charging",
|
||||||
|
"charging_requirement_mobile_backup": "Background backup requires the device to be charging",
|
||||||
"check_corrupt_asset_backup": "Check for corrupt asset backups",
|
"check_corrupt_asset_backup": "Check for corrupt asset backups",
|
||||||
"check_corrupt_asset_backup_button": "Perform check",
|
"check_corrupt_asset_backup_button": "Perform check",
|
||||||
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
||||||
|
|
@ -740,6 +753,7 @@
|
||||||
"create_user": "Create user",
|
"create_user": "Create user",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"created_at": "Created",
|
"created_at": "Created",
|
||||||
|
"creating_linked_albums": "Creating linked albums...",
|
||||||
"crop": "Crop",
|
"crop": "Crop",
|
||||||
"curated_object_page_title": "Things",
|
"curated_object_page_title": "Things",
|
||||||
"current_device": "Current device",
|
"current_device": "Current device",
|
||||||
|
|
@ -889,7 +903,9 @@
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"error_change_sort_album": "Failed to change album sort order",
|
"error_change_sort_album": "Failed to change album sort order",
|
||||||
"error_delete_face": "Error deleting face from asset",
|
"error_delete_face": "Error deleting face from asset",
|
||||||
|
"error_getting_places": "Error getting places",
|
||||||
"error_loading_image": "Error loading image",
|
"error_loading_image": "Error loading image",
|
||||||
|
"error_loading_partners": "Error loading partners: {error}",
|
||||||
"error_saving_image": "Error: {error}",
|
"error_saving_image": "Error: {error}",
|
||||||
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
|
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
|
||||||
"error_title": "Error - Something went wrong",
|
"error_title": "Error - Something went wrong",
|
||||||
|
|
@ -904,6 +920,7 @@
|
||||||
"cant_get_number_of_comments": "Can't get number of comments",
|
"cant_get_number_of_comments": "Can't get number of comments",
|
||||||
"cant_search_people": "Can't search people",
|
"cant_search_people": "Can't search people",
|
||||||
"cant_search_places": "Can't search places",
|
"cant_search_places": "Can't search places",
|
||||||
|
"clipboard_unsupported_mime_type": "The system clipboard does not support copying this type of content: {mimeType}",
|
||||||
"error_adding_assets_to_album": "Error adding assets to album",
|
"error_adding_assets_to_album": "Error adding assets to album",
|
||||||
"error_adding_users_to_album": "Error adding users to album",
|
"error_adding_users_to_album": "Error adding users to album",
|
||||||
"error_deleting_shared_user": "Error deleting shared user",
|
"error_deleting_shared_user": "Error deleting shared user",
|
||||||
|
|
@ -1054,6 +1071,7 @@
|
||||||
"favorites_page_no_favorites": "No favorite assets found",
|
"favorites_page_no_favorites": "No favorite assets found",
|
||||||
"feature_photo_updated": "Feature photo updated",
|
"feature_photo_updated": "Feature photo updated",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
|
"features_in_development": "Features in Development",
|
||||||
"features_setting_description": "Manage the app features",
|
"features_setting_description": "Manage the app features",
|
||||||
"file_name": "File name",
|
"file_name": "File name",
|
||||||
"file_name_or_extension": "File name or extension",
|
"file_name_or_extension": "File name or extension",
|
||||||
|
|
@ -1217,6 +1235,7 @@
|
||||||
"local": "Local",
|
"local": "Local",
|
||||||
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
|
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
|
||||||
"local_assets": "Local Assets",
|
"local_assets": "Local Assets",
|
||||||
|
"local_media_summary": "Local Media Summary",
|
||||||
"local_network": "Local network",
|
"local_network": "Local network",
|
||||||
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
||||||
"location_permission": "Location permission",
|
"location_permission": "Location permission",
|
||||||
|
|
@ -1228,6 +1247,7 @@
|
||||||
"location_picker_longitude_hint": "Enter your longitude here",
|
"location_picker_longitude_hint": "Enter your longitude here",
|
||||||
"lock": "Lock",
|
"lock": "Lock",
|
||||||
"locked_folder": "Locked Folder",
|
"locked_folder": "Locked Folder",
|
||||||
|
"log_detail_title": "Log Detail",
|
||||||
"log_out": "Log out",
|
"log_out": "Log out",
|
||||||
"log_out_all_devices": "Log Out All Devices",
|
"log_out_all_devices": "Log Out All Devices",
|
||||||
"logged_in_as": "Logged in as {user}",
|
"logged_in_as": "Logged in as {user}",
|
||||||
|
|
@ -1258,6 +1278,7 @@
|
||||||
"login_password_changed_success": "Password updated successfully",
|
"login_password_changed_success": "Password updated successfully",
|
||||||
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
||||||
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
|
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
|
||||||
|
"logs": "Logs",
|
||||||
"longitude": "Longitude",
|
"longitude": "Longitude",
|
||||||
"look": "Look",
|
"look": "Look",
|
||||||
"loop_videos": "Loop videos",
|
"loop_videos": "Loop videos",
|
||||||
|
|
@ -1300,6 +1321,7 @@
|
||||||
"mark_as_read": "Mark as read",
|
"mark_as_read": "Mark as read",
|
||||||
"marked_all_as_read": "Marked all as read",
|
"marked_all_as_read": "Marked all as read",
|
||||||
"matches": "Matches",
|
"matches": "Matches",
|
||||||
|
"matching_assets": "Matching Assets",
|
||||||
"media_type": "Media type",
|
"media_type": "Media type",
|
||||||
"memories": "Memories",
|
"memories": "Memories",
|
||||||
"memories_all_caught_up": "All caught up",
|
"memories_all_caught_up": "All caught up",
|
||||||
|
|
@ -1340,6 +1362,7 @@
|
||||||
"name_or_nickname": "Name or nickname",
|
"name_or_nickname": "Name or nickname",
|
||||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||||
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
||||||
|
"network_requirements": "Network Requirements",
|
||||||
"network_requirements_updated": "Network requirements changed, resetting backup queue",
|
"network_requirements_updated": "Network requirements changed, resetting backup queue",
|
||||||
"networking_settings": "Networking",
|
"networking_settings": "Networking",
|
||||||
"networking_subtitle": "Manage the server endpoint settings",
|
"networking_subtitle": "Manage the server endpoint settings",
|
||||||
|
|
@ -1350,6 +1373,7 @@
|
||||||
"new_person": "New person",
|
"new_person": "New person",
|
||||||
"new_pin_code": "New PIN code",
|
"new_pin_code": "New PIN code",
|
||||||
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
|
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
|
||||||
|
"new_timeline": "New Timeline",
|
||||||
"new_user_created": "New user created",
|
"new_user_created": "New user created",
|
||||||
"new_version_available": "NEW VERSION AVAILABLE",
|
"new_version_available": "NEW VERSION AVAILABLE",
|
||||||
"newest_first": "Newest first",
|
"newest_first": "Newest first",
|
||||||
|
|
@ -1363,20 +1387,25 @@
|
||||||
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
|
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
|
||||||
"no_assets_to_show": "No assets to show",
|
"no_assets_to_show": "No assets to show",
|
||||||
"no_cast_devices_found": "No cast devices found",
|
"no_cast_devices_found": "No cast devices found",
|
||||||
|
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||||
|
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
||||||
"no_duplicates_found": "No duplicates were found.",
|
"no_duplicates_found": "No duplicates were found.",
|
||||||
"no_exif_info_available": "No exif info available",
|
"no_exif_info_available": "No exif info available",
|
||||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||||
|
"no_local_assets_found": "No local assets found with this checksum",
|
||||||
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
|
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
|
||||||
"no_name": "No Name",
|
"no_name": "No Name",
|
||||||
"no_notifications": "No notifications",
|
"no_notifications": "No notifications",
|
||||||
"no_people_found": "No matching people found",
|
"no_people_found": "No matching people found",
|
||||||
"no_places": "No places",
|
"no_places": "No places",
|
||||||
|
"no_remote_assets_found": "No remote assets found with this checksum",
|
||||||
"no_results": "No results",
|
"no_results": "No results",
|
||||||
"no_results_description": "Try a synonym or more general keyword",
|
"no_results_description": "Try a synonym or more general keyword",
|
||||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||||
"no_uploads_in_progress": "No uploads in progress",
|
"no_uploads_in_progress": "No uploads in progress",
|
||||||
|
"not_available": "N/A",
|
||||||
"not_in_any_album": "Not in any album",
|
"not_in_any_album": "Not in any album",
|
||||||
"not_selected": "Not selected",
|
"not_selected": "Not selected",
|
||||||
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
|
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
|
||||||
|
|
@ -1587,6 +1616,7 @@
|
||||||
"regenerating_thumbnails": "Regenerating thumbnails",
|
"regenerating_thumbnails": "Regenerating thumbnails",
|
||||||
"remote": "Remote",
|
"remote": "Remote",
|
||||||
"remote_assets": "Remote Assets",
|
"remote_assets": "Remote Assets",
|
||||||
|
"remote_media_summary": "Remote Media Summary",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
|
"remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
|
||||||
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
|
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
|
||||||
|
|
@ -1867,6 +1897,7 @@
|
||||||
"show_slideshow_transition": "Show slideshow transition",
|
"show_slideshow_transition": "Show slideshow transition",
|
||||||
"show_supporter_badge": "Supporter badge",
|
"show_supporter_badge": "Supporter badge",
|
||||||
"show_supporter_badge_description": "Show a supporter badge",
|
"show_supporter_badge_description": "Show a supporter badge",
|
||||||
|
"show_text_search_menu": "Show text search menu",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"sidebar_display_description": "Display a link to the view in the sidebar",
|
"sidebar_display_description": "Display a link to the view in the sidebar",
|
||||||
|
|
@ -1897,6 +1928,7 @@
|
||||||
"stacktrace": "Stacktrace",
|
"stacktrace": "Stacktrace",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
"start_date": "Start date",
|
"start_date": "Start date",
|
||||||
|
"start_date_before_end_date": "Start date must be before end date",
|
||||||
"state": "State",
|
"state": "State",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"stop_casting": "Stop casting",
|
"stop_casting": "Stop casting",
|
||||||
|
|
@ -2099,5 +2131,6 @@
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"you_dont_have_any_shared_links": "You don't have any shared links",
|
"you_dont_have_any_shared_links": "You don't have any shared links",
|
||||||
"your_wifi_name": "Your Wi-Fi name",
|
"your_wifi_name": "Your Wi-Fi name",
|
||||||
"zoom_image": "Zoom Image"
|
"zoom_image": "Zoom Image",
|
||||||
|
"zoom_to_bounds": "Zoom to bounds"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ FROM builder-cpu AS builder-rknn
|
||||||
|
|
||||||
# Warning: 25GiB+ disk space required to pull this image
|
# Warning: 25GiB+ disk space required to pull this image
|
||||||
# TODO: find a way to reduce the image size
|
# TODO: find a way to reduce the image size
|
||||||
FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
|
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
|
||||||
|
|
||||||
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
||||||
ARG ONNXRUNTIME_VERSION="v1.20.1"
|
ARG ONNXRUNTIME_VERSION="v1.20.1"
|
||||||
|
|
@ -99,7 +99,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
||||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||||
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
||||||
|
|
||||||
FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
|
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
|
||||||
|
|
||||||
FROM prod-cpu AS prod-armnn
|
FROM prod-cpu AS prod-armnn
|
||||||
|
|
||||||
|
|
|
||||||
34
mise.lock
34
mise.lock
|
|
@ -1,34 +0,0 @@
|
||||||
[tools.dart]
|
|
||||||
version = "3.8.2"
|
|
||||||
backend = "asdf:dart"
|
|
||||||
|
|
||||||
[tools.flutter]
|
|
||||||
version = "3.32.8-stable"
|
|
||||||
backend = "asdf:flutter"
|
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
|
||||||
version = "1.31.4"
|
|
||||||
backend = "github:CQLabs/homebrew-dcm"
|
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm".platforms.linux-x64]
|
|
||||||
checksum = "blake3:e9df5b765df327e1248fccf2c6165a89d632a065667f99c01765bf3047b94955"
|
|
||||||
size = 8821083
|
|
||||||
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip"
|
|
||||||
|
|
||||||
[tools.node]
|
|
||||||
version = "22.18.0"
|
|
||||||
backend = "core:node"
|
|
||||||
|
|
||||||
[tools.node.platforms.linux-x64]
|
|
||||||
checksum = "sha256:a2e703725d8683be86bb5da967bf8272f4518bdaf10f21389e2b2c9eaeae8c8a"
|
|
||||||
size = 54824343
|
|
||||||
url = "https://nodejs.org/dist/v22.18.0/node-v22.18.0-linux-x64.tar.gz"
|
|
||||||
|
|
||||||
[tools.pnpm]
|
|
||||||
version = "10.14.0"
|
|
||||||
backend = "aqua:pnpm/pnpm"
|
|
||||||
|
|
||||||
[tools.pnpm.platforms.linux-x64]
|
|
||||||
checksum = "blake3:13dfa46b7173d3cad3bad60a756a492ecf0bce48b23eb9f793e7ccec5a09b46d"
|
|
||||||
size = 66231525
|
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.14.0/pnpm-linux-x64"
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[tools]
|
[tools]
|
||||||
node = "22.19.0"
|
node = "22.19.0"
|
||||||
flutter = "3.32.8"
|
flutter = "3.35.4"
|
||||||
pnpm = "10.14.0"
|
pnpm = "10.15.1"
|
||||||
dart = "3.8.2"
|
dart = "3.8.2"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
|
|
@ -11,7 +11,6 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
experimental = true
|
experimental = true
|
||||||
lockfile = true
|
|
||||||
pin = true
|
pin = true
|
||||||
|
|
||||||
# .github
|
# .github
|
||||||
|
|
@ -300,7 +299,7 @@ run = "tsc --noEmit"
|
||||||
depends = "web:svelte-kit-sync"
|
depends = "web:svelte-kit-sync"
|
||||||
env._.path = "web/node_modules/.bin"
|
env._.path = "web/node_modules/.bin"
|
||||||
dir = "web"
|
dir = "web"
|
||||||
run = "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte"
|
run = "svelte-check --no-tsconfig --fail-on-warnings"
|
||||||
|
|
||||||
[tasks."web:checklist"]
|
[tasks."web:checklist"]
|
||||||
run = [
|
run = [
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"flutter": "3.32.8"
|
"flutter": "3.35.4"
|
||||||
}
|
}
|
||||||
4
mobile/.vscode/settings.json
vendored
4
mobile/.vscode/settings.json
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"dart.flutterSdkPath": ".fvm/versions/3.32.8",
|
"dart.flutterSdkPath": ".fvm/versions/3.35.4",
|
||||||
"dart.lineLength": 120,
|
"dart.lineLength": 120,
|
||||||
"[dart]": {
|
"[dart]": {
|
||||||
"editor.rulers": [120],
|
"editor.rulers": [120]
|
||||||
},
|
},
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/.fvm": true
|
"**/.fvm": true
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,9 @@ analyzer:
|
||||||
- lib/**/*.g.dart
|
- lib/**/*.g.dart
|
||||||
- lib/**/*.drift.dart
|
- lib/**/*.drift.dart
|
||||||
|
|
||||||
plugins:
|
# TODO: Re-enable after upgrading custom_lint
|
||||||
- custom_lint
|
# plugins:
|
||||||
|
# - custom_lint
|
||||||
|
|
||||||
custom_lint:
|
custom_lint:
|
||||||
debug: true
|
debug: true
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package app.alextran.immich
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||||
|
|
||||||
class ImmichApp : Application() {
|
class ImmichApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|
@ -14,6 +15,8 @@ class ImmichApp : Application() {
|
||||||
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||||
// (because of low memory etc.), the backup is never performed.
|
// (because of low memory etc.), the backup is never performed.
|
||||||
// As a workaround, we also run a backup check when initializing the application
|
// As a workaround, we also run a backup check when initializing the application
|
||||||
|
|
||||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||||
|
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package app.alextran.immich
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ext.SdkExtensions
|
import android.os.ext.SdkExtensions
|
||||||
|
import app.alextran.immich.background.BackgroundEngineLock
|
||||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||||
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||||
import app.alextran.immich.connectivity.ConnectivityApi
|
import app.alextran.immich.connectivity.ConnectivityApi
|
||||||
|
|
@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||||
|
flutterEngine.plugins.add(BackgroundEngineLock())
|
||||||
|
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
val nativeSyncApiImpl =
|
val nativeSyncApiImpl =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package app.alextran.immich.background
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
private const val TAG = "BackgroundEngineLock"
|
||||||
|
|
||||||
|
class BackgroundEngineLock : FlutterPlugin {
|
||||||
|
companion object {
|
||||||
|
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||||
|
var engineCount = AtomicInteger(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
// work manager task is running while the main app is opened, cancel the worker
|
||||||
|
if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance()
|
||||||
|
.get(ENGINE_CACHE_KEY) != null
|
||||||
|
) {
|
||||||
|
WorkManager.getInstance(binding.applicationContext)
|
||||||
|
.cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME)
|
||||||
|
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
engineCount.decrementAndGet()
|
||||||
|
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,36 @@ private object BackgroundWorkerPigeonUtils {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||||
|
if (a is ByteArray && b is ByteArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is IntArray && b is IntArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is LongArray && b is LongArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is DoubleArray && b is DoubleArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is Array<*> && b is Array<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is List<*> && b is List<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is Map<*, *> && b is Map<*, *>) {
|
||||||
|
return a.size == b.size && a.all {
|
||||||
|
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||||
|
deepEquals(it.value, b[it.key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,18 +80,63 @@ class FlutterError (
|
||||||
override val message: String? = null,
|
override val message: String? = null,
|
||||||
val details: Any? = null
|
val details: Any? = null
|
||||||
) : Throwable()
|
) : Throwable()
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class BackgroundWorkerSettings (
|
||||||
|
val requiresCharging: Boolean,
|
||||||
|
val minimumDelaySeconds: Long
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): BackgroundWorkerSettings {
|
||||||
|
val requiresCharging = pigeonVar_list[0] as Boolean
|
||||||
|
val minimumDelaySeconds = pigeonVar_list[1] as Long
|
||||||
|
return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
requiresCharging,
|
||||||
|
minimumDelaySeconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is BackgroundWorkerSettings) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return super.readValueOfType(type, buffer)
|
return when (type) {
|
||||||
|
129.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
BackgroundWorkerSettings.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
super.writeValue(stream, value)
|
when (value) {
|
||||||
|
is BackgroundWorkerSettings -> {
|
||||||
|
stream.write(129)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
else -> super.writeValue(stream, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerFgHostApi {
|
interface BackgroundWorkerFgHostApi {
|
||||||
fun enable()
|
fun enable()
|
||||||
|
fun configure(settings: BackgroundWorkerSettings)
|
||||||
fun disable()
|
fun disable()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val settingsArg = args[0] as BackgroundWorkerSettings
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.configure(settingsArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
BackgroundWorkerPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
run {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.google.common.util.concurrent.SettableFuture
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
import io.flutter.FlutterInjector
|
import io.flutter.FlutterInjector
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
import io.flutter.embedding.engine.dart.DartExecutor
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||||
|
|
||||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||||
engine = FlutterEngine(ctx)
|
engine = FlutterEngine(ctx)
|
||||||
|
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||||
|
FlutterEngineCache.getInstance()
|
||||||
|
.put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
|
||||||
|
|
||||||
// Register custom plugins
|
// Register custom plugins
|
||||||
MainActivity.registerPlugins(ctx, engine!!)
|
MainActivity.registerPlugins(ctx, engine!!)
|
||||||
|
|
@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||||
isComplete = true
|
isComplete = true
|
||||||
engine?.destroy()
|
engine?.destroy()
|
||||||
engine = null
|
engine = null
|
||||||
|
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||||
flutterApi = null
|
flutterApi = null
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
waitForForegroundPromotion()
|
waitForForegroundPromotion()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package app.alextran.immich.background
|
package app.alextran.immich.background
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
|
|
@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
private const val TAG = "BackgroundUploadImpl"
|
private const val TAG = "BackgroundWorkerApiImpl"
|
||||||
|
|
||||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
enqueueMediaObserver(ctx)
|
enqueueMediaObserver(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun configure(settings: BackgroundWorkerSettings) {
|
||||||
|
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
||||||
|
enqueueMediaObserver(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
override fun disable() {
|
override fun disable() {
|
||||||
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
|
WorkManager.getInstance(ctx).apply {
|
||||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||||
|
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||||
|
}
|
||||||
Log.i(TAG, "Cancelled background upload tasks")
|
Log.i(TAG, "Cancelled background upload tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||||
|
|
||||||
fun enqueueMediaObserver(ctx: Context) {
|
fun enqueueMediaObserver(ctx: Context) {
|
||||||
val constraints = Constraints.Builder()
|
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||||
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
val constraints = Constraints.Builder().apply {
|
||||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
||||||
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
||||||
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
|
addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||||
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
|
setTriggerContentUpdateDelay(settings.minimumDelaySeconds, TimeUnit.SECONDS)
|
||||||
.build()
|
setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES)
|
||||||
|
setRequiresCharging(settings.requiresCharging)
|
||||||
|
}.build()
|
||||||
|
|
||||||
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
|
|
@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
WorkManager.getInstance(ctx)
|
WorkManager.getInstance(ctx)
|
||||||
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
||||||
|
|
||||||
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enqueueBackgroundWorker(ctx: Context) {
|
fun enqueueBackgroundWorker(ctx: Context) {
|
||||||
|
|
@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(ctx)
|
WorkManager.getInstance(ctx)
|
||||||
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
|
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
|
||||||
|
|
||||||
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class BackgroundWorkerPreferences(private val ctx: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
|
||||||
|
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
|
||||||
|
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
|
||||||
|
|
||||||
|
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
|
||||||
|
private const val DEFAULT_REQUIRE_CHARGING = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sp: SharedPreferences by lazy {
|
||||||
|
ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSettings(settings: BackgroundWorkerSettings) {
|
||||||
|
sp.edit().apply {
|
||||||
|
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
|
||||||
|
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSettings(): BackgroundWorkerSettings {
|
||||||
|
return BackgroundWorkerSettings(
|
||||||
|
minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
|
||||||
|
requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.provider.MediaStore.Images
|
import android.provider.MediaStore.Images
|
||||||
import android.provider.MediaStore.Video
|
import android.provider.MediaStore.Video
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
|
|
@ -19,7 +18,6 @@ import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.HashMap
|
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
|
|
@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||||
val source = ImageDecoder.createSource(resolver, uri)
|
val source = ImageDecoder.createSource(resolver, uri)
|
||||||
signal.throwIfCanceled()
|
signal.throwIfCanceled()
|
||||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||||
val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
|
if (targetWidth > 0 && targetHeight > 0) {
|
||||||
decoder.setTargetSampleSize(sampleSize)
|
val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
|
||||||
|
decoder.setTargetSampleSize(sample)
|
||||||
|
}
|
||||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,40 @@ data class SyncDelta (
|
||||||
|
|
||||||
override fun hashCode(): Int = toList().hashCode()
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class HashResult (
|
||||||
|
val assetId: String,
|
||||||
|
val error: String? = null,
|
||||||
|
val hash: String? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): HashResult {
|
||||||
|
val assetId = pigeonVar_list[0] as String
|
||||||
|
val error = pigeonVar_list[1] as String?
|
||||||
|
val hash = pigeonVar_list[2] as String?
|
||||||
|
return HashResult(assetId, error, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
assetId,
|
||||||
|
error,
|
||||||
|
hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is HashResult) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
|
|
@ -227,6 +261,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||||
SyncDelta.fromList(it)
|
SyncDelta.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
132.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
HashResult.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> super.readValueOfType(type, buffer)
|
else -> super.readValueOfType(type, buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,11 +283,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||||
stream.write(131)
|
stream.write(131)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
|
is HashResult -> {
|
||||||
|
stream.write(132)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
else -> super.writeValue(stream, value)
|
else -> super.writeValue(stream, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface NativeSyncApi {
|
interface NativeSyncApi {
|
||||||
fun shouldFullSync(): Boolean
|
fun shouldFullSync(): Boolean
|
||||||
|
|
@ -259,7 +303,8 @@ interface NativeSyncApi {
|
||||||
fun getAlbums(): List<PlatformAlbum>
|
fun getAlbums(): List<PlatformAlbum>
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||||
fun hashPaths(paths: List<String>): List<ByteArray?>
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
|
fun cancelHashing()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
|
|
@ -402,13 +447,33 @@ interface NativeSyncApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
run {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val pathsArg = args[0] as List<String>
|
val assetIdsArg = args[0] as List<String>
|
||||||
|
val allowNetworkAccessArg = args[1] as Boolean
|
||||||
|
api.hashAssets(assetIdsArg, allowNetworkAccessArg) { result: Result<List<HashResult>> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
val wrapped: List<Any?> = try {
|
||||||
listOf(api.hashPaths(pathsArg))
|
api.cancelHashing()
|
||||||
|
listOf(null)
|
||||||
} catch (exception: Throwable) {
|
} catch (exception: Throwable) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
MessagesPigeonUtils.wrapError(exception)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,25 @@
|
||||||
package app.alextran.immich.sync
|
package app.alextran.immich.sync
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Base64
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
sealed class AssetResult {
|
sealed class AssetResult {
|
||||||
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
||||||
|
|
@ -19,8 +30,12 @@ sealed class AssetResult {
|
||||||
open class NativeSyncApiImplBase(context: Context) {
|
open class NativeSyncApiImplBase(context: Context) {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
|
private var hashTask: Job? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NativeSyncApiImplBase"
|
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||||
|
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
||||||
|
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
||||||
|
|
||||||
const val MEDIA_SELECTION =
|
const val MEDIA_SELECTION =
|
||||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||||
|
|
@ -215,23 +230,74 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hashPaths(paths: List<String>): List<ByteArray?> {
|
fun hashAssets(
|
||||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
assetIds: List<String>,
|
||||||
val digest = MessageDigest.getInstance("SHA-1")
|
// allowNetworkAccess is only used on the iOS implementation
|
||||||
|
@Suppress("UNUSED_PARAMETER") allowNetworkAccess: Boolean,
|
||||||
|
callback: (Result<List<HashResult>>) -> Unit
|
||||||
|
) {
|
||||||
|
if (assetIds.isEmpty()) {
|
||||||
|
callback(Result.success(emptyList()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return paths.map { path ->
|
hashTask?.cancel()
|
||||||
|
hashTask = CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
FileInputStream(path).use { file ->
|
val results = assetIds.map { assetId ->
|
||||||
|
async {
|
||||||
|
hashSemaphore.withPermit {
|
||||||
|
ensureActive()
|
||||||
|
hashAsset(assetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
|
||||||
|
callback(Result.success(results))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
callback(
|
||||||
|
Result.failure(
|
||||||
|
FlutterError(
|
||||||
|
HASHING_CANCELLED_CODE,
|
||||||
|
"Hashing operation was cancelled",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun hashAsset(assetId: String): HashResult {
|
||||||
|
return try {
|
||||||
|
val assetUri = ContentUris.withAppendedId(
|
||||||
|
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||||
|
assetId.toLong()
|
||||||
|
)
|
||||||
|
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1")
|
||||||
|
ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
|
||||||
var bytesRead: Int
|
var bytesRead: Int
|
||||||
while (file.read(buffer).also { bytesRead = it } > 0) {
|
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||||
|
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
digest.update(buffer, 0, bytesRead)
|
digest.update(buffer, 0, bytesRead)
|
||||||
}
|
}
|
||||||
}
|
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
|
||||||
digest.digest()
|
|
||||||
|
val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
|
||||||
|
HashResult(assetId, null, hashString)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to hash file $path: $e")
|
HashResult(assetId, "Failed to hash asset: ${e.message}", null)
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelHashing() {
|
||||||
|
hashTask?.cancel()
|
||||||
|
hashTask = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
mobile/drift_schemas/main/drift_schema_v11.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v11.json
generated
Normal file
File diff suppressed because one or more lines are too long
1
mobile/ios/.gitignore
vendored
1
mobile/ios/.gitignore
vendored
|
|
@ -4,7 +4,6 @@
|
||||||
*.moved-aside
|
*.moved-aside
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
*.perspectivev3
|
*.perspectivev3
|
||||||
**/*sync/
|
|
||||||
.sconsign.dblite
|
.sconsign.dblite
|
||||||
.tags*
|
.tags*
|
||||||
**/.vagrant/
|
**/.vagrant/
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,6 @@
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>12.0</string>
|
<string>13.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ SPEC CHECKSUMS:
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,119 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||||
return value as! T?
|
return value as! T?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||||
|
let cleanLhs = nilOrValue(lhs) as Any?
|
||||||
|
let cleanRhs = nilOrValue(rhs) as Any?
|
||||||
|
switch (cleanLhs, cleanRhs) {
|
||||||
|
case (nil, nil):
|
||||||
|
return true
|
||||||
|
|
||||||
|
case (nil, _), (_, nil):
|
||||||
|
return false
|
||||||
|
|
||||||
|
case is (Void, Void):
|
||||||
|
return true
|
||||||
|
|
||||||
|
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
|
||||||
|
return cleanLhsHashable == cleanRhsHashable
|
||||||
|
|
||||||
|
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
|
||||||
|
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
|
||||||
|
for (index, element) in cleanLhsArray.enumerated() {
|
||||||
|
if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||||
|
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
|
||||||
|
for (key, cleanLhsValue) in cleanLhsDictionary {
|
||||||
|
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
|
||||||
|
if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
|
||||||
|
if let valueList = value as? [AnyHashable] {
|
||||||
|
for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let valueDict = value as? [AnyHashable: AnyHashable] {
|
||||||
|
for key in valueDict.keys {
|
||||||
|
hasher.combine(key)
|
||||||
|
deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let hashableValue = value as? AnyHashable {
|
||||||
|
hasher.combine(hashableValue.hashValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasher.combine(String(describing: value))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
|
struct BackgroundWorkerSettings: Hashable {
|
||||||
|
var requiresCharging: Bool
|
||||||
|
var minimumDelaySeconds: Int64
|
||||||
|
|
||||||
|
|
||||||
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
|
static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
|
||||||
|
let requiresCharging = pigeonVar_list[0] as! Bool
|
||||||
|
let minimumDelaySeconds = pigeonVar_list[1] as! Int64
|
||||||
|
|
||||||
|
return BackgroundWorkerSettings(
|
||||||
|
requiresCharging: requiresCharging,
|
||||||
|
minimumDelaySeconds: minimumDelaySeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
func toList() -> [Any?] {
|
||||||
|
return [
|
||||||
|
requiresCharging,
|
||||||
|
minimumDelaySeconds,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
|
||||||
|
return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
deepHashBackgroundWorker(value: toList(), hasher: &hasher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
|
||||||
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
|
switch type {
|
||||||
|
case 129:
|
||||||
|
return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
|
||||||
|
default:
|
||||||
|
return super.readValue(ofType: type)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
|
||||||
|
override func writeValue(_ value: Any) {
|
||||||
|
if let value = value as? BackgroundWorkerSettings {
|
||||||
|
super.writeByte(129)
|
||||||
|
super.writeValue(value.toList())
|
||||||
|
} else {
|
||||||
|
super.writeValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||||
|
|
@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol BackgroundWorkerFgHostApi {
|
protocol BackgroundWorkerFgHostApi {
|
||||||
func enable() throws
|
func enable() throws
|
||||||
|
func configure(settings: BackgroundWorkerSettings) throws
|
||||||
func disable() throws
|
func disable() throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup {
|
||||||
} else {
|
} else {
|
||||||
enableChannel.setMessageHandler(nil)
|
enableChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
configureChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let settingsArg = args[0] as! BackgroundWorkerSettings
|
||||||
|
do {
|
||||||
|
try api.configure(settings: settingsArg)
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
configureChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
disableChannel.setMessageHandler { _, reply in
|
disableChannel.setMessageHandler { _, reply in
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,22 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
func enable() throws {
|
func enable() throws {
|
||||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||||
print("BackgroundUploadImpl:enbale Background worker scheduled")
|
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(settings: BackgroundWorkerSettings) throws {
|
||||||
|
// Android only
|
||||||
}
|
}
|
||||||
|
|
||||||
func disable() throws {
|
func disable() throws {
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||||
print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
|
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||||
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
|
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
|
||||||
|
private static let taskSemaphore = DispatchSemaphore(value: 1)
|
||||||
|
|
||||||
public static func registerBackgroundWorkers() {
|
public static func registerBackgroundWorkers() {
|
||||||
BGTaskScheduler.shared.register(
|
BGTaskScheduler.shared.register(
|
||||||
|
|
@ -59,12 +64,18 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
|
|
||||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
||||||
scheduleRefreshWorker()
|
scheduleRefreshWorker()
|
||||||
|
// If another task is running, cede the background time back to the OS
|
||||||
|
if taskSemaphore.wait(timeout: .now()) == .success {
|
||||||
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
|
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
|
||||||
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
|
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
|
||||||
|
} else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
||||||
scheduleProcessingWorker()
|
scheduleProcessingWorker()
|
||||||
|
taskSemaphore.wait()
|
||||||
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
|
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
|
||||||
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
|
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +91,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
* - maxSeconds: Optional timeout for the operation in seconds
|
* - maxSeconds: Optional timeout for the operation in seconds
|
||||||
*/
|
*/
|
||||||
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
|
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
|
||||||
|
defer { taskSemaphore.signal() }
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
var isSuccess = true
|
var isSuccess = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi {
|
||||||
var image: UIImage?
|
var image: UIImage?
|
||||||
Self.imageManager.requestImage(
|
Self.imageManager.requestImage(
|
||||||
for: asset,
|
for: asset,
|
||||||
targetSize: CGSize(width: Double(width), height: Double(height)),
|
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
|
||||||
contentMode: .aspectFill,
|
contentMode: .aspectFill,
|
||||||
options: Self.requestOptions,
|
options: Self.requestOptions,
|
||||||
resultHandler: { (_image, info) -> Void in
|
resultHandler: { (_image, info) -> Void in
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,39 @@ struct SyncDelta: Hashable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
|
struct HashResult: Hashable {
|
||||||
|
var assetId: String
|
||||||
|
var error: String? = nil
|
||||||
|
var hash: String? = nil
|
||||||
|
|
||||||
|
|
||||||
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
|
static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? {
|
||||||
|
let assetId = pigeonVar_list[0] as! String
|
||||||
|
let error: String? = nilOrValue(pigeonVar_list[1])
|
||||||
|
let hash: String? = nilOrValue(pigeonVar_list[2])
|
||||||
|
|
||||||
|
return HashResult(
|
||||||
|
assetId: assetId,
|
||||||
|
error: error,
|
||||||
|
hash: hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
func toList() -> [Any?] {
|
||||||
|
return [
|
||||||
|
assetId,
|
||||||
|
error,
|
||||||
|
hash,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
static func == (lhs: HashResult, rhs: HashResult) -> Bool {
|
||||||
|
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
deepHashMessages(value: toList(), hasher: &hasher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||||
override func readValue(ofType type: UInt8) -> Any? {
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
switch type {
|
switch type {
|
||||||
|
|
@ -276,6 +309,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||||
case 131:
|
case 131:
|
||||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||||
|
case 132:
|
||||||
|
return HashResult.fromList(self.readValue() as! [Any?])
|
||||||
default:
|
default:
|
||||||
return super.readValue(ofType: type)
|
return super.readValue(ofType: type)
|
||||||
}
|
}
|
||||||
|
|
@ -293,6 +328,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||||
} else if let value = value as? SyncDelta {
|
} else if let value = value as? SyncDelta {
|
||||||
super.writeByte(131)
|
super.writeByte(131)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
|
} else if let value = value as? HashResult {
|
||||||
|
super.writeByte(132)
|
||||||
|
super.writeValue(value.toList())
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(value)
|
super.writeValue(value)
|
||||||
}
|
}
|
||||||
|
|
@ -313,6 +351,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||||
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
|
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol NativeSyncApi {
|
protocol NativeSyncApi {
|
||||||
func shouldFullSync() throws -> Bool
|
func shouldFullSync() throws -> Bool
|
||||||
|
|
@ -323,7 +362,8 @@ protocol NativeSyncApi {
|
||||||
func getAlbums() throws -> [PlatformAlbum]
|
func getAlbums() throws -> [PlatformAlbum]
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||||
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?]
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
|
func cancelHashing() throws
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
|
|
@ -459,22 +499,38 @@ class NativeSyncApiSetup {
|
||||||
} else {
|
} else {
|
||||||
getAssetsForAlbumChannel.setMessageHandler(nil)
|
getAssetsForAlbumChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
let hashPathsChannel = taskQueue == nil
|
let hashAssetsChannel = taskQueue == nil
|
||||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
hashPathsChannel.setMessageHandler { message, reply in
|
hashAssetsChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let pathsArg = args[0] as! [String]
|
let assetIdsArg = args[0] as! [String]
|
||||||
|
let allowNetworkAccessArg = args[1] as! Bool
|
||||||
|
api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hashAssetsChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
cancelHashingChannel.setMessageHandler { _, reply in
|
||||||
do {
|
do {
|
||||||
let result = try api.hashPaths(paths: pathsArg)
|
try api.cancelHashing()
|
||||||
reply(wrapResult(result))
|
reply(wrapResult(nil))
|
||||||
} catch {
|
} catch {
|
||||||
reply(wrapError(error))
|
reply(wrapError(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hashPathsChannel.setMessageHandler(nil)
|
cancelHashingChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,30 +17,16 @@ struct AssetWrapper: Hashable, Equatable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PHAsset {
|
|
||||||
func toPlatformAsset() -> PlatformAsset {
|
|
||||||
return PlatformAsset(
|
|
||||||
id: localIdentifier,
|
|
||||||
name: title(),
|
|
||||||
type: Int64(mediaType.rawValue),
|
|
||||||
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
|
|
||||||
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
|
||||||
width: Int64(pixelWidth),
|
|
||||||
height: Int64(pixelHeight),
|
|
||||||
durationInSeconds: Int64(duration),
|
|
||||||
orientation: 0,
|
|
||||||
isFavorite: isFavorite
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NativeSyncApiImpl: NativeSyncApi {
|
class NativeSyncApiImpl: NativeSyncApi {
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
private let recoveredAlbumSubType = 1000000219
|
private let recoveredAlbumSubType = 1000000219
|
||||||
|
|
||||||
private let hashBufferSize = 2 * 1024 * 1024
|
private var hashTask: Task<Void, Error>?
|
||||||
|
private static let hashCancelledCode = "HASH_CANCELLED"
|
||||||
|
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
||||||
|
|
||||||
|
|
||||||
init(with defaults: UserDefaults = .standard) {
|
init(with defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
|
@ -267,23 +253,114 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
||||||
return paths.map { path in
|
if let prevTask = hashTask {
|
||||||
guard let file = FileHandle(forReadingAtPath: path) else {
|
prevTask.cancel()
|
||||||
print("Cannot open file: \(path)")
|
hashTask = nil
|
||||||
|
}
|
||||||
|
hashTask = Task { [weak self] in
|
||||||
|
var missingAssetIds = Set(assetIds)
|
||||||
|
var assets = [PHAsset]()
|
||||||
|
assets.reserveCapacity(assetIds.count)
|
||||||
|
PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
|
||||||
|
if Task.isCancelled {
|
||||||
|
stop.pointee = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
missingAssetIds.remove(asset.localIdentifier)
|
||||||
|
assets.append(asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
return completion(Self.hashCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||||
|
var results = [HashResult]()
|
||||||
|
results.reserveCapacity(assets.count)
|
||||||
|
for asset in assets {
|
||||||
|
if Task.isCancelled {
|
||||||
|
return completion(Self.hashCancelled)
|
||||||
|
}
|
||||||
|
taskGroup.addTask {
|
||||||
|
guard let self = self else { return nil }
|
||||||
|
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in taskGroup {
|
||||||
|
guard let result = result else {
|
||||||
|
return completion(Self.hashCancelled)
|
||||||
|
}
|
||||||
|
results.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
for missing in missingAssetIds {
|
||||||
|
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(.success(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelHashing() {
|
||||||
|
hashTask?.cancel()
|
||||||
|
hashTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
||||||
|
class RequestRef {
|
||||||
|
var id: PHAssetResourceDataRequestID?
|
||||||
|
}
|
||||||
|
let requestRef = RequestRef()
|
||||||
|
return await withTaskCancellationHandler(operation: {
|
||||||
|
if Task.isCancelled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasher = Insecure.SHA1()
|
guard let resource = asset.getResource() else {
|
||||||
while autoreleasepool(invoking: {
|
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
|
||||||
let chunk = file.readData(ofLength: hashBufferSize)
|
|
||||||
guard !chunk.isEmpty else { return false }
|
|
||||||
hasher.update(data: chunk)
|
|
||||||
return true
|
|
||||||
}) { }
|
|
||||||
|
|
||||||
let digest = hasher.finalize()
|
|
||||||
return FlutterStandardTypedData(bytes: Data(digest))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = PHAssetResourceRequestOptions()
|
||||||
|
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
var hasher = Insecure.SHA1()
|
||||||
|
|
||||||
|
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||||
|
for: resource,
|
||||||
|
options: options,
|
||||||
|
dataReceivedHandler: { data in
|
||||||
|
hasher.update(data: data)
|
||||||
|
},
|
||||||
|
completionHandler: { error in
|
||||||
|
let result: HashResult? = switch (error) {
|
||||||
|
case let e as PHPhotosError where e.code == .userCancelled: nil
|
||||||
|
case let .some(e): HashResult(
|
||||||
|
assetId: asset.localIdentifier,
|
||||||
|
error: "Failed to hash asset: \(e.localizedDescription)",
|
||||||
|
hash: nil
|
||||||
|
)
|
||||||
|
case .none:
|
||||||
|
HashResult(
|
||||||
|
assetId: asset.localIdentifier,
|
||||||
|
error: nil,
|
||||||
|
hash: Data(hasher.finalize()).base64EncodedString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, onCancel: {
|
||||||
|
guard let requestId = requestRef.id else { return }
|
||||||
|
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
mobile/ios/Runner/Sync/PHAssetExtensions.swift
Normal file
77
mobile/ios/Runner/Sync/PHAssetExtensions.swift
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
extension PHAsset {
|
||||||
|
func toPlatformAsset() -> PlatformAsset {
|
||||||
|
return PlatformAsset(
|
||||||
|
id: localIdentifier,
|
||||||
|
name: title,
|
||||||
|
type: Int64(mediaType.rawValue),
|
||||||
|
createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
|
||||||
|
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
||||||
|
width: Int64(pixelWidth),
|
||||||
|
height: Int64(pixelHeight),
|
||||||
|
durationInSeconds: Int64(duration),
|
||||||
|
orientation: 0,
|
||||||
|
isFavorite: isFavorite
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
return filename ?? originalFilename ?? "<unknown>"
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename: String? {
|
||||||
|
return value(forKey: "filename") as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
|
||||||
|
var originalFilename: String? {
|
||||||
|
return getResource()?.originalFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
func getResource() -> PHAssetResource? {
|
||||||
|
let resources = PHAssetResource.assetResources(for: self)
|
||||||
|
|
||||||
|
let filteredResources = resources.filter { $0.isMediaResource && isValidResourceType($0.type) }
|
||||||
|
|
||||||
|
guard !filteredResources.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if filteredResources.count == 1 {
|
||||||
|
return filteredResources.first
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentResource = filteredResources.first(where: { $0.isCurrent }) {
|
||||||
|
return currentResource
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fullSizeResource = filteredResources.first(where: { isFullSizeResourceType($0.type) }) {
|
||||||
|
return fullSizeResource
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||||
|
switch mediaType {
|
||||||
|
case .image:
|
||||||
|
return [.photo, .alternatePhoto, .fullSizePhoto].contains(type)
|
||||||
|
case .video:
|
||||||
|
return [.video, .fullSizeVideo, .fullSizePairedVideo].contains(type)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFullSizeResourceType(_ type: PHAssetResourceType) -> Bool {
|
||||||
|
switch mediaType {
|
||||||
|
case .image:
|
||||||
|
return type == .fullSizePhoto
|
||||||
|
case .video:
|
||||||
|
return type == .fullSizeVideo
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift
Normal file
16
mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
extension PHAssetResource {
|
||||||
|
var isCurrent: Bool {
|
||||||
|
return value(forKey: "isCurrent") as? Bool ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMediaResource: Bool {
|
||||||
|
var isMedia = type != .adjustmentData
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
isMedia = isMedia && type != .photoProxy
|
||||||
|
}
|
||||||
|
return isMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
const int noDbId = -9223372036854775808; // from Isar
|
const int noDbId = -9223372036854775808; // from Isar
|
||||||
const double downloadCompleted = -1;
|
const double downloadCompleted = -1;
|
||||||
const double downloadFailed = -2;
|
const double downloadFailed = -2;
|
||||||
|
|
@ -10,7 +12,7 @@ const int kSyncEventBatchSize = 5000;
|
||||||
const int kFetchLocalAssetsBatchSize = 40000;
|
const int kFetchLocalAssetsBatchSize = 40000;
|
||||||
|
|
||||||
// Hash batch limits
|
// Hash batch limits
|
||||||
const int kBatchHashFileLimit = 256;
|
final int kBatchHashFileLimit = Platform.isIOS ? 32 : 512;
|
||||||
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||||
|
|
||||||
// Secure storage keys
|
// Secure storage keys
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,12 @@ class AssetService {
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
||||||
if (asset.stackId == null) {
|
if (asset.stackId == null) {
|
||||||
return [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return _remoteAssetRepository.getStackChildren(asset).then((assets) {
|
final stack = await _remoteAssetRepository.getStackChildren(asset);
|
||||||
// Include the primary asset in the stack as the first item
|
// Include the primary asset in the stack as the first item
|
||||||
return [asset, ...assets];
|
return [asset, ...stack];
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,17 @@ class BackgroundWorkerFgService {
|
||||||
// TODO: Move this call to native side once old timeline is removed
|
// TODO: Move this call to native side once old timeline is removed
|
||||||
Future<void> enable() => _foregroundHostApi.enable();
|
Future<void> enable() => _foregroundHostApi.enable();
|
||||||
|
|
||||||
|
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
||||||
|
BackgroundWorkerSettings(
|
||||||
|
minimumDelaySeconds:
|
||||||
|
minimumDelaySeconds ??
|
||||||
|
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
||||||
|
requiresCharging:
|
||||||
|
requireCharging ??
|
||||||
|
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> disable() => _foregroundHostApi.disable();
|
Future<void> disable() => _foregroundHostApi.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +184,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
|
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
|
||||||
|
final nativeSyncApi = _ref.read(nativeSyncApiProvider);
|
||||||
_isCleanedUp = true;
|
_isCleanedUp = true;
|
||||||
_ref.dispose();
|
_ref.dispose();
|
||||||
|
|
||||||
|
|
@ -188,7 +200,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
_drift.close(),
|
_drift.close(),
|
||||||
_driftLogger.close(),
|
_driftLogger.close(),
|
||||||
backgroundSyncManager.cancel(),
|
backgroundSyncManager.cancel(),
|
||||||
backgroundSyncManager.cancelLocal(),
|
nativeSyncApi.cancelHashing(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (_isar.isOpen) {
|
if (_isar.isOpen) {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
import 'dart:convert';
|
import 'package:flutter/services.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/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||||
|
|
||||||
class HashService {
|
class HashService {
|
||||||
final int batchSizeLimit;
|
final int _batchSize;
|
||||||
final int batchFileLimit;
|
|
||||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final StorageRepository _storageRepository;
|
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
final _log = Logger('HashService');
|
final _log = Logger('HashService');
|
||||||
|
|
@ -22,26 +20,23 @@ class HashService {
|
||||||
HashService({
|
HashService({
|
||||||
required DriftLocalAlbumRepository localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required StorageRepository storageRepository,
|
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
this.batchSizeLimit = kBatchHashSizeLimit,
|
int? batchSize,
|
||||||
this.batchFileLimit = kBatchHashFileLimit,
|
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_storageRepository = storageRepository,
|
|
||||||
_cancelChecker = cancelChecker,
|
_cancelChecker = cancelChecker,
|
||||||
_nativeSyncApi = nativeSyncApi;
|
_nativeSyncApi = nativeSyncApi,
|
||||||
|
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||||
|
|
||||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||||
|
|
||||||
Future<void> hashAssets() async {
|
Future<void> hashAssets() async {
|
||||||
_log.info("Starting hashing of assets");
|
_log.info("Starting hashing of assets");
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
|
try {
|
||||||
// Sorted by backupSelection followed by isCloud
|
// Sorted by backupSelection followed by isCloud
|
||||||
final localAlbums = await _localAlbumRepository.getAll(
|
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||||
sortBy: {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum},
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final album in localAlbums) {
|
for (final album in localAlbums) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
|
|
@ -54,6 +49,14 @@ class HashService {
|
||||||
await _hashAssets(album, assetsToHash);
|
await _hashAssets(album, assetsToHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code == _kHashCancelledCode) {
|
||||||
|
_log.warning("Hashing cancelled by platform");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e, s) {
|
||||||
|
_log.severe("Error during hashing", e, s);
|
||||||
|
}
|
||||||
|
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||||
|
|
@ -63,8 +66,7 @@ class HashService {
|
||||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
||||||
int bytesProcessed = 0;
|
final toHash = <String, LocalAsset>{};
|
||||||
final toHash = <_AssetToPath>[];
|
|
||||||
|
|
||||||
for (final asset in assetsToHash) {
|
for (final asset in assetsToHash) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
|
|
@ -72,21 +74,10 @@ class HashService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
toHash[asset.id] = asset;
|
||||||
if (file == null) {
|
if (toHash.length == _batchSize) {
|
||||||
_log.warning(
|
|
||||||
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
bytesProcessed += await file.length();
|
|
||||||
toHash.add(_AssetToPath(asset: asset, path: file.path));
|
|
||||||
|
|
||||||
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
|
|
||||||
await _processBatch(album, toHash);
|
await _processBatch(album, toHash);
|
||||||
toHash.clear();
|
toHash.clear();
|
||||||
bytesProcessed = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,33 +85,36 @@ class HashService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes a batch of assets.
|
/// Processes a batch of assets.
|
||||||
Future<void> _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
|
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
|
||||||
if (toHash.isEmpty) {
|
if (toHash.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.fine("Hashing ${toHash.length} files");
|
_log.fine("Hashing ${toHash.length} files");
|
||||||
|
|
||||||
final hashed = <LocalAsset>[];
|
final hashed = <String, String>{};
|
||||||
final hashes = await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
|
final hashResults = await _nativeSyncApi.hashAssets(
|
||||||
|
toHash.keys.toList(),
|
||||||
|
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
hashes.length == toHash.length,
|
hashResults.length == toHash.length,
|
||||||
"Hashes length does not match toHash length: ${hashes.length} != ${toHash.length}",
|
"Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (int i = 0; i < hashes.length; i++) {
|
for (int i = 0; i < hashResults.length; i++) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
_log.warning("Hashing cancelled. Stopped processing batch.");
|
_log.warning("Hashing cancelled. Stopped processing batch.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final hash = hashes[i];
|
final hashResult = hashResults[i];
|
||||||
final asset = toHash[i].asset;
|
if (hashResult.hash != null) {
|
||||||
if (hash?.length == 20) {
|
hashed[hashResult.assetId] = hashResult.hash!;
|
||||||
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
|
|
||||||
} else {
|
} else {
|
||||||
|
final asset = toHash[hashResult.assetId];
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
|
"Failed to hash asset with id: ${hashResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,13 +122,5 @@ class HashService {
|
||||||
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
|
||||||
await _localAssetRepository.updateHashes(hashed);
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
await _storageRepository.clearCache();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AssetToPath {
|
|
||||||
final LocalAsset asset;
|
|
||||||
final String path;
|
|
||||||
|
|
||||||
const _AssetToPath({required this.asset, required this.path});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
|
||||||
|
|
||||||
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
|
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
// Used for mark & sweep
|
||||||
|
BoolColumn get marker_ => boolean().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {assetId, albumId};
|
Set<Column> get primaryKey => {assetId, albumId};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@ typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder =
|
||||||
i1.LocalAlbumAssetEntityCompanion Function({
|
i1.LocalAlbumAssetEntityCompanion Function({
|
||||||
required String assetId,
|
required String assetId,
|
||||||
required String albumId,
|
required String albumId,
|
||||||
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
|
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
|
||||||
i1.LocalAlbumAssetEntityCompanion Function({
|
i1.LocalAlbumAssetEntityCompanion Function({
|
||||||
i0.Value<String> assetId,
|
i0.Value<String> assetId,
|
||||||
i0.Value<String> albumId,
|
i0.Value<String> albumId,
|
||||||
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
|
|
||||||
final class $$LocalAlbumAssetEntityTableReferences
|
final class $$LocalAlbumAssetEntityTableReferences
|
||||||
|
|
@ -113,6 +115,11 @@ class $$LocalAlbumAssetEntityTableFilterComposer
|
||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
|
||||||
|
column: $table.marker_,
|
||||||
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableFilterComposer get assetId {
|
i3.$$LocalAssetEntityTableFilterComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
|
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||||
composer: this,
|
composer: this,
|
||||||
|
|
@ -177,6 +184,11 @@ class $$LocalAlbumAssetEntityTableOrderingComposer
|
||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
|
||||||
|
column: $table.marker_,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
|
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableOrderingComposer composer =
|
final i3.$$LocalAssetEntityTableOrderingComposer composer =
|
||||||
$composerBuilder(
|
$composerBuilder(
|
||||||
|
|
@ -243,6 +255,9 @@ class $$LocalAlbumAssetEntityTableAnnotationComposer
|
||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.GeneratedColumn<bool> get marker_ =>
|
||||||
|
$composableBuilder(column: $table.marker_, builder: (column) => column);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
|
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
|
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
|
||||||
$composerBuilder(
|
$composerBuilder(
|
||||||
|
|
@ -344,15 +359,21 @@ class $$LocalAlbumAssetEntityTableTableManager
|
||||||
({
|
({
|
||||||
i0.Value<String> assetId = const i0.Value.absent(),
|
i0.Value<String> assetId = const i0.Value.absent(),
|
||||||
i0.Value<String> albumId = const i0.Value.absent(),
|
i0.Value<String> albumId = const i0.Value.absent(),
|
||||||
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
}) => i1.LocalAlbumAssetEntityCompanion(
|
}) => i1.LocalAlbumAssetEntityCompanion(
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
albumId: albumId,
|
albumId: albumId,
|
||||||
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({required String assetId, required String albumId}) =>
|
({
|
||||||
i1.LocalAlbumAssetEntityCompanion.insert(
|
required String assetId,
|
||||||
|
required String albumId,
|
||||||
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
|
}) => i1.LocalAlbumAssetEntityCompanion.insert(
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
albumId: albumId,
|
albumId: albumId,
|
||||||
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
|
|
@ -477,8 +498,22 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
||||||
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
|
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
|
||||||
|
'marker_',
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns => [assetId, albumId];
|
late final i0.GeneratedColumn<bool> marker_ = i0.GeneratedColumn<bool>(
|
||||||
|
'marker',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i0.DriftSqlType.bool,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||||
|
'CHECK ("marker" IN (0, 1))',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<i0.GeneratedColumn> get $columns => [assetId, albumId, marker_];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@override
|
@override
|
||||||
|
|
@ -507,6 +542,12 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
||||||
} else if (isInserting) {
|
} else if (isInserting) {
|
||||||
context.missing(_albumIdMeta);
|
context.missing(_albumIdMeta);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('marker')) {
|
||||||
|
context.handle(
|
||||||
|
_marker_Meta,
|
||||||
|
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,6 +568,10 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
||||||
i0.DriftSqlType.string,
|
i0.DriftSqlType.string,
|
||||||
data['${effectivePrefix}album_id'],
|
data['${effectivePrefix}album_id'],
|
||||||
)!,
|
)!,
|
||||||
|
marker_: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.bool,
|
||||||
|
data['${effectivePrefix}marker'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,15 +590,20 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
||||||
implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
|
implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final String albumId;
|
final String albumId;
|
||||||
|
final bool? marker_;
|
||||||
const LocalAlbumAssetEntityData({
|
const LocalAlbumAssetEntityData({
|
||||||
required this.assetId,
|
required this.assetId,
|
||||||
required this.albumId,
|
required this.albumId,
|
||||||
|
this.marker_,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
final map = <String, i0.Expression>{};
|
final map = <String, i0.Expression>{};
|
||||||
map['asset_id'] = i0.Variable<String>(assetId);
|
map['asset_id'] = i0.Variable<String>(assetId);
|
||||||
map['album_id'] = i0.Variable<String>(albumId);
|
map['album_id'] = i0.Variable<String>(albumId);
|
||||||
|
if (!nullToAbsent || marker_ != null) {
|
||||||
|
map['marker'] = i0.Variable<bool>(marker_);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -565,6 +615,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
||||||
return LocalAlbumAssetEntityData(
|
return LocalAlbumAssetEntityData(
|
||||||
assetId: serializer.fromJson<String>(json['assetId']),
|
assetId: serializer.fromJson<String>(json['assetId']),
|
||||||
albumId: serializer.fromJson<String>(json['albumId']),
|
albumId: serializer.fromJson<String>(json['albumId']),
|
||||||
|
marker_: serializer.fromJson<bool?>(json['marker_']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
|
|
@ -573,13 +624,18 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'assetId': serializer.toJson<String>(assetId),
|
'assetId': serializer.toJson<String>(assetId),
|
||||||
'albumId': serializer.toJson<String>(albumId),
|
'albumId': serializer.toJson<String>(albumId),
|
||||||
|
'marker_': serializer.toJson<bool?>(marker_),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
|
i1.LocalAlbumAssetEntityData copyWith({
|
||||||
i1.LocalAlbumAssetEntityData(
|
String? assetId,
|
||||||
|
String? albumId,
|
||||||
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
|
}) => i1.LocalAlbumAssetEntityData(
|
||||||
assetId: assetId ?? this.assetId,
|
assetId: assetId ?? this.assetId,
|
||||||
albumId: albumId ?? this.albumId,
|
albumId: albumId ?? this.albumId,
|
||||||
|
marker_: marker_.present ? marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
LocalAlbumAssetEntityData copyWithCompanion(
|
LocalAlbumAssetEntityData copyWithCompanion(
|
||||||
i1.LocalAlbumAssetEntityCompanion data,
|
i1.LocalAlbumAssetEntityCompanion data,
|
||||||
|
|
@ -587,6 +643,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
||||||
return LocalAlbumAssetEntityData(
|
return LocalAlbumAssetEntityData(
|
||||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
||||||
albumId: data.albumId.present ? data.albumId.value : this.albumId,
|
albumId: data.albumId.present ? data.albumId.value : this.albumId,
|
||||||
|
marker_: data.marker_.present ? data.marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,51 +651,60 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
||||||
String toString() {
|
String toString() {
|
||||||
return (StringBuffer('LocalAlbumAssetEntityData(')
|
return (StringBuffer('LocalAlbumAssetEntityData(')
|
||||||
..write('assetId: $assetId, ')
|
..write('assetId: $assetId, ')
|
||||||
..write('albumId: $albumId')
|
..write('albumId: $albumId, ')
|
||||||
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(assetId, albumId);
|
int get hashCode => Object.hash(assetId, albumId, marker_);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
(other is i1.LocalAlbumAssetEntityData &&
|
(other is i1.LocalAlbumAssetEntityData &&
|
||||||
other.assetId == this.assetId &&
|
other.assetId == this.assetId &&
|
||||||
other.albumId == this.albumId);
|
other.albumId == this.albumId &&
|
||||||
|
other.marker_ == this.marker_);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAlbumAssetEntityCompanion
|
class LocalAlbumAssetEntityCompanion
|
||||||
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
|
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
|
||||||
final i0.Value<String> assetId;
|
final i0.Value<String> assetId;
|
||||||
final i0.Value<String> albumId;
|
final i0.Value<String> albumId;
|
||||||
|
final i0.Value<bool?> marker_;
|
||||||
const LocalAlbumAssetEntityCompanion({
|
const LocalAlbumAssetEntityCompanion({
|
||||||
this.assetId = const i0.Value.absent(),
|
this.assetId = const i0.Value.absent(),
|
||||||
this.albumId = const i0.Value.absent(),
|
this.albumId = const i0.Value.absent(),
|
||||||
|
this.marker_ = const i0.Value.absent(),
|
||||||
});
|
});
|
||||||
LocalAlbumAssetEntityCompanion.insert({
|
LocalAlbumAssetEntityCompanion.insert({
|
||||||
required String assetId,
|
required String assetId,
|
||||||
required String albumId,
|
required String albumId,
|
||||||
|
this.marker_ = const i0.Value.absent(),
|
||||||
}) : assetId = i0.Value(assetId),
|
}) : assetId = i0.Value(assetId),
|
||||||
albumId = i0.Value(albumId);
|
albumId = i0.Value(albumId);
|
||||||
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
|
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
|
||||||
i0.Expression<String>? assetId,
|
i0.Expression<String>? assetId,
|
||||||
i0.Expression<String>? albumId,
|
i0.Expression<String>? albumId,
|
||||||
|
i0.Expression<bool>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (assetId != null) 'asset_id': assetId,
|
if (assetId != null) 'asset_id': assetId,
|
||||||
if (albumId != null) 'album_id': albumId,
|
if (albumId != null) 'album_id': albumId,
|
||||||
|
if (marker_ != null) 'marker': marker_,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
i1.LocalAlbumAssetEntityCompanion copyWith({
|
i1.LocalAlbumAssetEntityCompanion copyWith({
|
||||||
i0.Value<String>? assetId,
|
i0.Value<String>? assetId,
|
||||||
i0.Value<String>? albumId,
|
i0.Value<String>? albumId,
|
||||||
|
i0.Value<bool?>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i1.LocalAlbumAssetEntityCompanion(
|
return i1.LocalAlbumAssetEntityCompanion(
|
||||||
assetId: assetId ?? this.assetId,
|
assetId: assetId ?? this.assetId,
|
||||||
albumId: albumId ?? this.albumId,
|
albumId: albumId ?? this.albumId,
|
||||||
|
marker_: marker_ ?? this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -651,6 +717,9 @@ class LocalAlbumAssetEntityCompanion
|
||||||
if (albumId.present) {
|
if (albumId.present) {
|
||||||
map['album_id'] = i0.Variable<String>(albumId.value);
|
map['album_id'] = i0.Variable<String>(albumId.value);
|
||||||
}
|
}
|
||||||
|
if (marker_.present) {
|
||||||
|
map['marker'] = i0.Variable<bool>(marker_.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -658,7 +727,8 @@ class LocalAlbumAssetEntityCompanion
|
||||||
String toString() {
|
String toString() {
|
||||||
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
|
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
|
||||||
..write('assetId: $assetId, ')
|
..write('assetId: $assetId, ')
|
||||||
..write('albumId: $albumId')
|
..write('albumId: $albumId, ')
|
||||||
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 10;
|
int get schemaVersion => 11;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
|
@ -156,6 +156,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||||
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
||||||
await m.alterTable(TableMigration(v10.userEntity));
|
await m.alterTable(TableMigration(v10.userEntity));
|
||||||
},
|
},
|
||||||
|
from10To11: (m, v11) async {
|
||||||
|
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4270,6 +4270,395 @@ i1.GeneratedColumn<String> _column_94(String aliasedName) =>
|
||||||
true,
|
true,
|
||||||
type: i1.DriftSqlType.string,
|
type: i1.DriftSqlType.string,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final class Schema11 extends i0.VersionedSchema {
|
||||||
|
Schema11({required super.database}) : super(version: 11);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
userEntity,
|
||||||
|
remoteAssetEntity,
|
||||||
|
stackEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
remoteAlbumEntity,
|
||||||
|
localAlbumEntity,
|
||||||
|
localAlbumAssetEntity,
|
||||||
|
idxLocalAssetChecksum,
|
||||||
|
idxRemoteAssetOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
idxRemoteAssetChecksum,
|
||||||
|
authUserEntity,
|
||||||
|
userMetadataEntity,
|
||||||
|
partnerEntity,
|
||||||
|
remoteExifEntity,
|
||||||
|
remoteAlbumAssetEntity,
|
||||||
|
remoteAlbumUserEntity,
|
||||||
|
memoryEntity,
|
||||||
|
memoryAssetEntity,
|
||||||
|
personEntity,
|
||||||
|
assetFaceEntity,
|
||||||
|
storeEntity,
|
||||||
|
idxLatLng,
|
||||||
|
];
|
||||||
|
late final Shape20 userEntity = Shape20(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_91,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape17 remoteAssetEntity = Shape17(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_13,
|
||||||
|
_column_14,
|
||||||
|
_column_15,
|
||||||
|
_column_16,
|
||||||
|
_column_17,
|
||||||
|
_column_18,
|
||||||
|
_column_19,
|
||||||
|
_column_20,
|
||||||
|
_column_21,
|
||||||
|
_column_86,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape3 stackEntity = Shape3(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'stack_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape2 localAssetEntity = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_22,
|
||||||
|
_column_14,
|
||||||
|
_column_23,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape9 remoteAlbumEntity = Shape9(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_56,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_57,
|
||||||
|
_column_58,
|
||||||
|
_column_59,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape19 localAlbumEntity = Shape19(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_5,
|
||||||
|
_column_31,
|
||||||
|
_column_32,
|
||||||
|
_column_90,
|
||||||
|
_column_33,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_34, _column_35, _column_33],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||||
|
'idx_local_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_owner_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_library_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
late final Shape21 authUserEntity = Shape21(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'auth_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_2,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_92,
|
||||||
|
_column_93,
|
||||||
|
_column_7,
|
||||||
|
_column_94,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape4 userMetadataEntity = Shape4(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_metadata_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||||
|
columns: [_column_25, _column_26, _column_27],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape5 partnerEntity = Shape5(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'partner_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||||
|
columns: [_column_28, _column_29, _column_30],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape8 remoteExifEntity = Shape8(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_exif_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_36,
|
||||||
|
_column_37,
|
||||||
|
_column_38,
|
||||||
|
_column_39,
|
||||||
|
_column_40,
|
||||||
|
_column_41,
|
||||||
|
_column_11,
|
||||||
|
_column_10,
|
||||||
|
_column_42,
|
||||||
|
_column_43,
|
||||||
|
_column_44,
|
||||||
|
_column_45,
|
||||||
|
_column_46,
|
||||||
|
_column_47,
|
||||||
|
_column_48,
|
||||||
|
_column_49,
|
||||||
|
_column_50,
|
||||||
|
_column_51,
|
||||||
|
_column_52,
|
||||||
|
_column_53,
|
||||||
|
_column_54,
|
||||||
|
_column_55,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_36, _column_60],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||||
|
columns: [_column_60, _column_25, _column_61],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape11 memoryEntity = Shape11(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_18,
|
||||||
|
_column_15,
|
||||||
|
_column_8,
|
||||||
|
_column_62,
|
||||||
|
_column_63,
|
||||||
|
_column_64,
|
||||||
|
_column_65,
|
||||||
|
_column_66,
|
||||||
|
_column_67,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape12 memoryAssetEntity = Shape12(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||||
|
columns: [_column_36, _column_68],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape14 personEntity = Shape14(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'person_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_1,
|
||||||
|
_column_69,
|
||||||
|
_column_71,
|
||||||
|
_column_72,
|
||||||
|
_column_73,
|
||||||
|
_column_74,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape15 assetFaceEntity = Shape15(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_face_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_36,
|
||||||
|
_column_76,
|
||||||
|
_column_77,
|
||||||
|
_column_78,
|
||||||
|
_column_79,
|
||||||
|
_column_80,
|
||||||
|
_column_81,
|
||||||
|
_column_82,
|
||||||
|
_column_83,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape18 storeEntity = Shape18(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'store_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_87, _column_88, _column_89],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLatLng = i1.Index(
|
||||||
|
'idx_lat_lng',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape22 extends i0.VersionedTable {
|
||||||
|
Shape22({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get assetId =>
|
||||||
|
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get albumId =>
|
||||||
|
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<bool> get marker_ =>
|
||||||
|
columnsByName['marker']! as i1.GeneratedColumn<bool>;
|
||||||
|
}
|
||||||
|
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
|
|
@ -4280,6 +4669,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
|
|
@ -4328,6 +4718,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from9To10(migrator, schema);
|
await from9To10(migrator, schema);
|
||||||
return 10;
|
return 10;
|
||||||
|
case 10:
|
||||||
|
final schema = Schema11(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from10To11(migrator, schema);
|
||||||
|
return 11;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
|
|
@ -4344,6 +4739,7 @@ i1.OnUpgrade stepByStep({
|
||||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
|
|
@ -4355,5 +4751,6 @@ i1.OnUpgrade stepByStep({
|
||||||
from7To8: from7To8,
|
from7To8: from7To8,
|
||||||
from8To9: from8To9,
|
from8To9: from8To9,
|
||||||
from9To10: from9To10,
|
from9To10: from9To10,
|
||||||
|
from10To11: from10To11,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -72,17 +72,33 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
final deleteSmt = _db.localAssetEntity.delete();
|
return _db.transaction(() async {
|
||||||
deleteSmt.where((localAsset) {
|
await _db.managers.localAlbumAssetEntity
|
||||||
final subQuery = _db.localAlbumAssetEntity.selectOnly()
|
.filter((row) => row.albumId.id.equals(albumId))
|
||||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
.update((album) => album(marker_: const Value(true)));
|
||||||
..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]);
|
|
||||||
subQuery.where(
|
await _db.batch((batch) {
|
||||||
_db.localAlbumEntity.id.equals(albumId) & _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep),
|
for (final assetId in assetIdsToKeep) {
|
||||||
|
batch.update(
|
||||||
|
_db.localAlbumAssetEntity,
|
||||||
|
const LocalAlbumAssetEntityCompanion(marker_: Value(null)),
|
||||||
|
where: (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
|
||||||
);
|
);
|
||||||
return localAsset.id.isInQuery(subQuery);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final query = _db.localAssetEntity.delete()
|
||||||
|
..where(
|
||||||
|
(row) => row.id.isInQuery(
|
||||||
|
_db.localAlbumAssetEntity.selectOnly()
|
||||||
|
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||||
|
..where(
|
||||||
|
_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAlbumAssetEntity.marker_.isNotNull(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await query.go();
|
||||||
});
|
});
|
||||||
await deleteSmt.go();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> upsert(
|
Future<void> upsert(
|
||||||
|
|
@ -198,10 +214,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||||
// List<String>
|
// List<String>
|
||||||
await _db.batch((batch) async {
|
await _db.batch((batch) async {
|
||||||
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
|
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
|
||||||
batch.deleteWhere(
|
for (final albumId in albumIds.cast<String?>().nonNulls) {
|
||||||
_db.localAlbumAssetEntity,
|
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
|
||||||
(f) => f.albumId.isNotIn(albumIds.cast<String?>().nonNulls) & f.assetId.equals(assetId),
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await _db.batch((batch) async {
|
await _db.batch((batch) async {
|
||||||
|
|
@ -288,13 +303,15 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
return transaction(() async {
|
return transaction(() async {
|
||||||
if (assetsToUnLink.isNotEmpty) {
|
if (assetsToUnLink.isNotEmpty) {
|
||||||
await _db.batch(
|
await _db.batch((batch) {
|
||||||
(batch) => batch.deleteWhere(
|
for (final assetId in assetsToUnLink) {
|
||||||
|
batch.deleteWhere(
|
||||||
_db.localAlbumAssetEntity,
|
_db.localAlbumAssetEntity,
|
||||||
(f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
|
(row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await _deleteAssets(assetsToDelete);
|
await _deleteAssets(assetsToDelete);
|
||||||
});
|
});
|
||||||
|
|
@ -320,7 +337,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) {
|
return _db.batch((batch) {
|
||||||
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
|
for (final id in ids) {
|
||||||
|
batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
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/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';
|
||||||
|
|
@ -36,17 +35,17 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
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(Map<String, String> hashes) {
|
||||||
if (hashes.isEmpty) {
|
if (hashes.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) async {
|
return _db.batch((batch) async {
|
||||||
for (final asset in hashes) {
|
for (final entry in hashes.entries) {
|
||||||
batch.update(
|
batch.update(
|
||||||
_db.localAssetEntity,
|
_db.localAssetEntity,
|
||||||
LocalAssetEntityCompanion(checksum: Value(asset.checksum)),
|
LocalAssetEntityCompanion(checksum: Value(entry.value)),
|
||||||
where: (e) => e.id.equals(asset.id),
|
where: (e) => e.id.equals(entry.key),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -58,8 +57,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) {
|
return _db.batch((batch) {
|
||||||
for (final slice in ids.slices(32000)) {
|
for (final id in ids) {
|
||||||
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
|
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> removeAssets(String albumId, List<String> assetIds) {
|
Future<void> removeAssets(String albumId, List<String> assetIds) {
|
||||||
return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds));
|
return _db.batch((batch) {
|
||||||
|
for (final assetId in assetIds) {
|
||||||
|
batch.deleteWhere(
|
||||||
|
_db.remoteAlbumAssetEntity,
|
||||||
|
(row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
||||||
|
|
|
||||||
|
|
@ -62,12 +62,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
||||||
if (asset.stackId == null) {
|
final stackId = asset.stackId;
|
||||||
return Future.value([]);
|
if (stackId == null) {
|
||||||
|
return Future.value(const []);
|
||||||
}
|
}
|
||||||
|
|
||||||
final query = _db.remoteAssetEntity.select()
|
final query = _db.remoteAssetEntity.select()
|
||||||
..where((row) => row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not())
|
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
|
||||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
||||||
|
|
||||||
return query.map((row) => row.toDto()).get();
|
return query.map((row) => row.toDto()).get();
|
||||||
|
|
@ -159,7 +160,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(List<String> ids) {
|
Future<void> delete(List<String> ids) {
|
||||||
return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids));
|
return _db.batch((batch) {
|
||||||
|
for (final id in ids) {
|
||||||
|
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateLocation(List<String> ids, LatLng location) {
|
Future<void> updateLocation(List<String> ids, LatLng location) {
|
||||||
|
|
@ -198,7 +203,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
.map((row) => row.id)
|
.map((row) => row.id)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
|
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
|
||||||
|
|
@ -218,15 +227,21 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
Future<void> unStack(List<String> stackIds) {
|
Future<void> unStack(List<String> stackIds) {
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: delete this after adding foreign key on stackId
|
// TODO: delete this after adding foreign key on stackId
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
batch.update(
|
batch.update(
|
||||||
_db.remoteAssetEntity,
|
_db.remoteAssetEntity,
|
||||||
const RemoteAssetEntityCompanion(stackId: Value(null)),
|
const RemoteAssetEntityCompanion(stackId: Value(null)),
|
||||||
where: (e) => e.stackId.isIn(stackIds),
|
where: (e) => e.stackId.equals(stackId),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class SearchApiRepository extends ApiRepository {
|
||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
type: type,
|
type: type,
|
||||||
page: page,
|
page: page,
|
||||||
size: 1000,
|
size: 100,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
|
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
|
await _db.batch((batch) {
|
||||||
|
for (final user in data) {
|
||||||
|
batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: SyncUserDeleteV1', error, stack);
|
_logger.severe('Error: SyncUserDeleteV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|
@ -158,7 +162,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
|
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
|
||||||
try {
|
try {
|
||||||
await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
|
await _db.batch((batch) {
|
||||||
|
for (final asset in data) {
|
||||||
|
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
|
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|
@ -243,7 +251,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
|
await _db.batch((batch) {
|
||||||
|
for (final album in data) {
|
||||||
|
batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteAlbumsV1', error, stack);
|
_logger.severe('Error: deleteAlbumsV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|
@ -379,7 +391,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
|
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId)));
|
await _db.batch((batch) {
|
||||||
|
for (final memory in data) {
|
||||||
|
batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteMemoriesV1', error, stack);
|
_logger.severe('Error: deleteMemoriesV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|
@ -443,7 +459,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
|
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
|
||||||
try {
|
try {
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId)));
|
await _db.batch((batch) {
|
||||||
|
for (final stack in data) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
|
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||||
album.activityEnabled = value;
|
album.activityEnabled = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
|
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
"comments_and_likes",
|
"comments_and_likes",
|
||||||
|
|
|
||||||
|
|
@ -205,9 +205,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildBackgroundBackupInfo() {
|
buildBackgroundBackupInfo() {
|
||||||
return const ListTile(
|
return ListTile(
|
||||||
leading: Icon(Icons.info_outline_rounded),
|
leading: const Icon(Icons.info_outline_rounded),
|
||||||
title: Text("Background backup is currently running, cannot start manual backup"),
|
title: Text('background_backup_running_error'.tr()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/sync_status.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/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||||
|
|
@ -33,7 +34,14 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
|
await ref.read(backgroundSyncProvider).syncRemote();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -44,7 +52,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
|
||||||
|
|
||||||
Future<void> startBackup() async {
|
Future<void> startBackup() async {
|
||||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||||
|
|
@ -52,7 +59,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await backgroundManager.syncRemote();
|
|
||||||
await backupNotifier.getBackupStatus(currentUser.id);
|
await backupNotifier.getBackupStatus(currentUser.id);
|
||||||
await backupNotifier.startBackup(currentUser.id);
|
await backupNotifier.startBackup(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
@ -235,11 +241,13 @@ class _BackupCard extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
|
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
|
||||||
|
final syncStatus = ref.watch(syncStatusProvider);
|
||||||
|
|
||||||
return BackupInfoCard(
|
return BackupInfoCard(
|
||||||
title: "backup_controller_page_backup".tr(),
|
title: "backup_controller_page_backup".tr(),
|
||||||
subtitle: "backup_controller_page_backup_sub".tr(),
|
subtitle: "backup_controller_page_backup_sub".tr(),
|
||||||
info: backupCount.toString(),
|
info: backupCount.toString(),
|
||||||
|
isLoading: syncStatus.isRemoteSyncing,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,10 +258,13 @@ class _RemainderCard extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
|
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
|
||||||
|
final syncStatus = ref.watch(syncStatusProvider);
|
||||||
|
|
||||||
return BackupInfoCard(
|
return BackupInfoCard(
|
||||||
title: "backup_controller_page_remainder".tr(),
|
title: "backup_controller_page_remainder".tr(),
|
||||||
subtitle: "backup_controller_page_remainder_sub".tr(),
|
subtitle: "backup_controller_page_remainder_sub".tr(),
|
||||||
info: remainderCount.toString(),
|
info: remainderCount.toString(),
|
||||||
|
isLoading: syncStatus.isRemoteSyncing,
|
||||||
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.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/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/sync_linked_album.service.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/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
||||||
|
|
@ -63,16 +67,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||||
});
|
});
|
||||||
await _handleLinkedAlbumFuture;
|
await _handleLinkedAlbumFuture;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart backup if total count changed and backup is enabled
|
|
||||||
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
|
||||||
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
|
||||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
|
||||||
|
|
||||||
if (totalChanged && isBackupEnabled) {
|
|
||||||
await ref.read(driftBackupProvider.notifier).cancel();
|
|
||||||
await ref.read(driftBackupProvider.notifier).startBackup(user.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -101,6 +95,27 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||||
onPopInvokedWithResult: (didPop, _) async {
|
onPopInvokedWithResult: (didPop, _) async {
|
||||||
if (!didPop) {
|
if (!didPop) {
|
||||||
await _handlePagePopped();
|
await _handlePagePopped();
|
||||||
|
|
||||||
|
final user = ref.read(currentUserProvider);
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||||
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
|
||||||
|
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||||
|
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
||||||
|
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||||
|
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||||
|
final nativeSync = ref.read(nativeSyncApiProvider);
|
||||||
|
if (totalChanged) {
|
||||||
|
// Waits for hashing to be cancelled before starting a new one
|
||||||
|
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
||||||
|
if (isBackupEnabled) {
|
||||||
|
unawaited(backupNotifier.cancel().whenComplete(() => backupNotifier.startBackup(user.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -249,7 +264,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(strokeWidth: 4),
|
const CircularProgressIndicator(strokeWidth: 4),
|
||||||
Text("Creating linked albums...", style: context.textTheme.labelLarge),
|
Text('creating_linked_albums'.tr(), style: context.textTheme.labelLarge),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.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/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
@ -58,8 +59,10 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (error, stackTrace) =>
|
error: (error, stackTrace) => Text(
|
||||||
Text('Error: $error', style: TextStyle(color: context.colorScheme.error)),
|
'error_saving_image'.tr(args: [error.toString()]),
|
||||||
|
style: TextStyle(color: context.colorScheme.error),
|
||||||
|
),
|
||||||
loading: () => const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
|
loading: () => const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -83,7 +86,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (Object error, StackTrace stackTrace) {
|
error: (Object error, StackTrace stackTrace) {
|
||||||
return Center(child: Text('Error: $error'));
|
return Center(child: Text('error_saving_image'.tr(args: [error.toString()])));
|
||||||
},
|
},
|
||||||
loading: () {
|
loading: () {
|
||||||
return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive()));
|
return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive()));
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
@ -8,7 +9,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/immich_logger.service.dart';
|
import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class AppLogPage extends HookConsumerWidget {
|
class AppLogPage extends HookConsumerWidget {
|
||||||
|
|
@ -49,7 +49,7 @@ class AppLogPage extends HookConsumerWidget {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Logs", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)),
|
title: Text('logs'.tr(), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)),
|
||||||
scrolledUnderElevation: 1,
|
scrolledUnderElevation: 1,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
actions: [
|
actions: [
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
@ -36,7 +37,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||||
context.scaffoldMessenger.showSnackBar(
|
context.scaffoldMessenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
"Copied to clipboard",
|
"copied_to_clipboard".tr(),
|
||||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -97,7 +98,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Log Detail")),
|
appBar: AppBar(title: Text("log_detail_title".tr())),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ class _SharedToPartnerList extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, stack) => Center(child: Text("Error loading partners: $error")),
|
error: (error, stack) => Center(child: Text('error_loading_partners'.tr(args: [error.toString()]))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ class PlacesCollectionPage extends HookConsumerWidget {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (error, stask) => const Text('Error getting places'),
|
error: (error, stask) => Text('error_getting_places'.tr()),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||||
return SwitchListTile.adaptive(
|
return SwitchListTile.adaptive(
|
||||||
value: showMetadata.value,
|
value: showMetadata.value,
|
||||||
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
|
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
|
||||||
activeColor: colorScheme.primary,
|
activeThumbColor: colorScheme.primary,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
|
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
|
||||||
);
|
);
|
||||||
|
|
@ -122,7 +122,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||||
return SwitchListTile.adaptive(
|
return SwitchListTile.adaptive(
|
||||||
value: allowDownload.value,
|
value: allowDownload.value,
|
||||||
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
|
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
|
||||||
activeColor: colorScheme.primary,
|
activeThumbColor: colorScheme.primary,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
"allow_public_user_to_download",
|
"allow_public_user_to_download",
|
||||||
|
|
@ -135,7 +135,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||||
return SwitchListTile.adaptive(
|
return SwitchListTile.adaptive(
|
||||||
value: allowUpload.value,
|
value: allowUpload.value,
|
||||||
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
|
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
|
||||||
activeColor: colorScheme.primary,
|
activeThumbColor: colorScheme.primary,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
"allow_public_user_to_upload",
|
"allow_public_user_to_upload",
|
||||||
|
|
@ -148,7 +148,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||||
return SwitchListTile.adaptive(
|
return SwitchListTile.adaptive(
|
||||||
value: editExpiry.value,
|
value: editExpiry.value,
|
||||||
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
|
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
|
||||||
activeColor: colorScheme.primary,
|
activeThumbColor: colorScheme.primary,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
"change_expiration_time",
|
"change_expiration_time",
|
||||||
|
|
|
||||||
|
|
@ -435,7 +435,7 @@ class SearchPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.more_vert_rounded),
|
icon: const Icon(Icons.more_vert_rounded),
|
||||||
tooltip: 'Show text search menu',
|
tooltip: 'show_text_search_menu'.tr(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
|
|
|
||||||
79
mobile/lib/platform/background_worker_api.g.dart
generated
79
mobile/lib/platform/background_worker_api.g.dart
generated
|
|
@ -25,6 +25,57 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
|
||||||
return <Object?>[error.code, error.message, error.details];
|
return <Object?>[error.code, error.message, error.details];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _deepEquals(Object? a, Object? b) {
|
||||||
|
if (a is List && b is List) {
|
||||||
|
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
|
}
|
||||||
|
if (a is Map && b is Map) {
|
||||||
|
return a.length == b.length &&
|
||||||
|
a.entries.every(
|
||||||
|
(MapEntry<Object?, Object?> entry) =>
|
||||||
|
(b as Map<Object?, Object?>).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return a == b;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundWorkerSettings {
|
||||||
|
BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
|
||||||
|
|
||||||
|
bool requiresCharging;
|
||||||
|
|
||||||
|
int minimumDelaySeconds;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[requiresCharging, minimumDelaySeconds];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static BackgroundWorkerSettings decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return BackgroundWorkerSettings(requiresCharging: result[0]! as bool, minimumDelaySeconds: result[1]! as int);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! BackgroundWorkerSettings || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(encode(), other.encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => Object.hashAll(_toList());
|
||||||
|
}
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
|
|
@ -32,6 +83,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
|
} else if (value is BackgroundWorkerSettings) {
|
||||||
|
buffer.putUint8(129);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +94,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||||
@override
|
@override
|
||||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 129:
|
||||||
|
return BackgroundWorkerSettings.decode(readValue(buffer)!);
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
return super.readValueOfType(type, buffer);
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +138,29 @@ class BackgroundWorkerFgHostApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> configure(BackgroundWorkerSettings settings) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[settings]);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> disable() async {
|
Future<void> disable() async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';
|
||||||
|
|
|
||||||
75
mobile/lib/platform/native_sync_api.g.dart
generated
75
mobile/lib/platform/native_sync_api.g.dart
generated
|
|
@ -205,6 +205,45 @@ class SyncDelta {
|
||||||
int get hashCode => Object.hashAll(_toList());
|
int get hashCode => Object.hashAll(_toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HashResult {
|
||||||
|
HashResult({required this.assetId, this.error, this.hash});
|
||||||
|
|
||||||
|
String assetId;
|
||||||
|
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
String? hash;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[assetId, error, hash];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static HashResult decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! HashResult || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(encode(), other.encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => Object.hashAll(_toList());
|
||||||
|
}
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
|
|
@ -221,6 +260,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||||
} else if (value is SyncDelta) {
|
} else if (value is SyncDelta) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
|
} else if (value is HashResult) {
|
||||||
|
buffer.putUint8(132);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
}
|
}
|
||||||
|
|
@ -235,6 +277,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||||
return PlatformAlbum.decode(readValue(buffer)!);
|
return PlatformAlbum.decode(readValue(buffer)!);
|
||||||
case 131:
|
case 131:
|
||||||
return SyncDelta.decode(readValue(buffer)!);
|
return SyncDelta.decode(readValue(buffer)!);
|
||||||
|
case 132:
|
||||||
|
return HashResult.decode(readValue(buffer)!);
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
return super.readValueOfType(type, buffer);
|
||||||
}
|
}
|
||||||
|
|
@ -468,15 +512,15 @@ class NativeSyncApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Uint8List?>> hashPaths(List<String> paths) async {
|
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[paths]);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetIds, allowNetworkAccess]);
|
||||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
if (pigeonVar_replyList == null) {
|
if (pigeonVar_replyList == null) {
|
||||||
throw _createConnectionError(pigeonVar_channelName);
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
|
@ -492,7 +536,30 @@ class NativeSyncApi {
|
||||||
message: 'Host platform returned null value for non-null return value.',
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<Uint8List?>();
|
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<HashResult>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelHashing() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:drift/drift.dart' hide Column;
|
import 'package:drift/drift.dart' hide Column;
|
||||||
|
import 'package:easy_localization/easy_localization.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/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
@ -135,7 +136,7 @@ class FeatInDevPage extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Features in Development'), centerTitle: true),
|
appBar: AppBar(title: Text('features_in_development'.tr()), centerTitle: true),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class MainTimelinePage extends ConsumerWidget {
|
||||||
return Timeline(
|
return Timeline(
|
||||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||||
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||||
|
showStorageIndicator: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.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/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
@ -55,7 +56,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Local Media Summary')),
|
appBar: AppBar(title: Text('local_media_summary'.tr())),
|
||||||
body: Consumer(
|
body: Consumer(
|
||||||
builder: (ctx, ref, __) {
|
builder: (ctx, ref, __) {
|
||||||
final db = ref.watch(driftProvider);
|
final db = ref.watch(driftProvider);
|
||||||
|
|
@ -78,7 +79,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 15),
|
padding: const EdgeInsets.only(left: 15),
|
||||||
child: Text("Album summary", style: ctx.textTheme.titleMedium),
|
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -135,7 +136,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Remote Media Summary')),
|
appBar: AppBar(title: Text('remote_media_summary'.tr())),
|
||||||
body: Consumer(
|
body: Consumer(
|
||||||
builder: (ctx, ref, __) {
|
builder: (ctx, ref, __) {
|
||||||
final db = ref.watch(driftProvider);
|
final db = ref.watch(driftProvider);
|
||||||
|
|
@ -158,7 +159,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 15),
|
padding: const EdgeInsets.only(left: 15),
|
||||||
child: Text("Album summary", style: ctx.textTheme.titleMedium),
|
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
57
mobile/lib/presentation/pages/download_info.page.dart
Normal file
57
mobile/lib/presentation/pages/download_info.page.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
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/pages/common/download_panel.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DownloadInfoPage extends ConsumerWidget {
|
||||||
|
const DownloadInfoPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
|
||||||
|
|
||||||
|
onCancelDownload(String id) {
|
||||||
|
ref.watch(downloadStateProvider.notifier).cancelDownload(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text("download".t(context: context)),
|
||||||
|
actions: [],
|
||||||
|
),
|
||||||
|
body: ListView.builder(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: tasks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final task = tasks[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||||
|
child: DownloadTaskTile(
|
||||||
|
progress: task.value.progress,
|
||||||
|
fileName: task.value.fileName,
|
||||||
|
status: task.value.status,
|
||||||
|
onCancelDownload: () => onCancelDownload(task.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
persistentFooterButtons: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
tasks.map((e) => e.key).forEach(onCancelDownload);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)),
|
||||||
|
child: Text(
|
||||||
|
'clear_all'.t(context: context),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -208,7 +208,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||||
activityEnabled.value = value;
|
activityEnabled.value = value;
|
||||||
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
|
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
|
||||||
},
|
},
|
||||||
activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
|
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
"comments_and_likes",
|
"comments_and_likes",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.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/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
@ -14,7 +15,7 @@ class AssetTroubleshootPage extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Asset Troubleshoot")),
|
appBar: AppBar(title: Text('asset_troubleshoot'.tr())),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
|
@ -37,20 +38,23 @@ class _AssetDetailsView extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
_AssetPropertiesSection(asset: asset),
|
_AssetPropertiesSection(asset: asset),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Matching Assets', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
Text(
|
||||||
|
'matching_assets'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
if (asset.checksum != null) ...[
|
if (asset.checksum != null) ...[
|
||||||
_LocalAssetsSection(asset: asset),
|
_LocalAssetsSection(asset: asset),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_RemoteAssetSection(asset: asset),
|
_RemoteAssetSection(asset: asset),
|
||||||
] else ...[
|
] else ...[
|
||||||
const _PropertySectionCard(
|
_PropertySectionCard(
|
||||||
title: 'Local Assets',
|
title: 'Local Assets',
|
||||||
properties: [_PropertyItem(label: 'Status', value: 'No checksum available - cannot fetch local assets')],
|
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_local'.tr())],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const _PropertySectionCard(
|
_PropertySectionCard(
|
||||||
title: 'Remote Assets',
|
title: 'Remote Assets',
|
||||||
properties: [_PropertyItem(label: 'Status', value: 'No checksum available - cannot fetch remote asset')],
|
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_remote'.tr())],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -222,9 +226,9 @@ class _LocalAssetsSection extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localAssets.isEmpty) {
|
if (localAssets.isEmpty) {
|
||||||
return const _PropertySectionCard(
|
return _PropertySectionCard(
|
||||||
title: 'Local Assets',
|
title: 'Local Assets',
|
||||||
properties: [_PropertyItem(label: 'Status', value: 'No local assets found with this checksum')],
|
properties: [_PropertyItem(label: 'Status', value: 'no_local_assets_found'.tr())],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,9 +285,9 @@ class _RemoteAssetSection extends ConsumerWidget {
|
||||||
final remoteAsset = snapshot.data;
|
final remoteAsset = snapshot.data;
|
||||||
|
|
||||||
if (remoteAsset == null) {
|
if (remoteAsset == null) {
|
||||||
return const _PropertySectionCard(
|
return _PropertySectionCard(
|
||||||
title: 'Remote Assets',
|
title: 'Remote Assets',
|
||||||
properties: [_PropertyItem(label: 'Status', value: 'No remote asset found with this checksum')],
|
properties: [_PropertyItem(label: 'Status', value: 'no_remote_assets_found'.tr())],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,7 +340,10 @@ class _PropertyItem extends StatelessWidget {
|
||||||
child: Text('$label:', style: const TextStyle(fontWeight: FontWeight.w500)),
|
child: Text('$label:', style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(value ?? 'N/A', style: TextStyle(color: Theme.of(context).colorScheme.secondary)),
|
child: Text(
|
||||||
|
value ?? 'not_available'.tr(),
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.secondary),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.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/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
|
|
@ -302,7 +303,9 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
||||||
}).toList();
|
}).toList();
|
||||||
},
|
},
|
||||||
error: (error, _) {
|
error: (error, _) {
|
||||||
return [Center(child: Text('Error: $error'))];
|
return [
|
||||||
|
Center(child: Text('error_saving_image'.tr(args: [error.toString()]))),
|
||||||
|
];
|
||||||
},
|
},
|
||||||
loading: () {
|
loading: () {
|
||||||
return [const Center(child: CircularProgressIndicator())];
|
return [const Center(child: CircularProgressIndicator())];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.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/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
@ -46,9 +47,9 @@ class _AlbumList extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
data: (albums) {
|
data: (albums) {
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return const SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(padding: EdgeInsets.all(20.0), child: Text('No albums found')),
|
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('no_albums_yet'.tr())),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class LocalTimelinePage extends StatelessWidget {
|
||||||
child: Timeline(
|
child: Timeline(
|
||||||
appBar: MesmerizingSliverAppBar(title: album.name),
|
appBar: MesmerizingSliverAppBar(title: album.name),
|
||||||
bottomSheet: const LocalAlbumBottomSheet(),
|
bottomSheet: const LocalAlbumBottomSheet(),
|
||||||
|
showStorageIndicator: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -440,7 +440,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.more_vert_rounded),
|
icon: const Icon(Icons.more_vert_rounded),
|
||||||
tooltip: 'Show text search menu',
|
tooltip: 'show_text_search_menu'.tr(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
|
|
@ -633,6 +633,7 @@ class _SearchResultGrid extends ConsumerWidget {
|
||||||
groupBy: GroupAssetsBy.none,
|
groupBy: GroupAssetsBy.none,
|
||||||
appBar: null,
|
appBar: null,
|
||||||
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
||||||
|
snapToMonth: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,45 @@
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.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/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/background_sync.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/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class DownloadActionButton extends ConsumerWidget {
|
class DownloadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool menuItem;
|
||||||
|
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
||||||
|
|
||||||
const DownloadActionButton({super.key, required this.source});
|
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).downloadAll(source);
|
try {
|
||||||
|
await ref.read(actionProvider.notifier).downloadAll(source);
|
||||||
|
|
||||||
|
Future.delayed(const Duration(seconds: 1), () async {
|
||||||
|
await backgroundSyncManager.syncLocal();
|
||||||
|
await backgroundSyncManager.hashAssets();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: ToastType.error,
|
|
||||||
);
|
|
||||||
} else if (result.count > 0) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: 'download_action_prompt'.t(context: context, args: {'count': result.count.toString()}),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: ToastType.success,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final backgroundManager = ref.watch(backgroundSyncProvider);
|
||||||
|
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.download,
|
iconData: Icons.download,
|
||||||
maxWidth: 95,
|
maxWidth: 95,
|
||||||
label: "download".t(context: context),
|
label: "download".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
menuItem: menuItem,
|
||||||
|
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
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/providers/asset_viewer/download.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class DownloadStatusFloatingButton extends ConsumerWidget {
|
||||||
|
const DownloadStatusFloatingButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final shouldShow = ref.watch(downloadStateProvider.select((state) => state.showProgress));
|
||||||
|
final itemCount = ref.watch(downloadStateProvider.select((state) => state.taskProgress.length));
|
||||||
|
final isDownloading = ref
|
||||||
|
.watch(downloadStateProvider.select((state) => state.taskProgress))
|
||||||
|
.values
|
||||||
|
.where((element) => element.progress != 1)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
|
return shouldShow
|
||||||
|
? Badge.count(
|
||||||
|
count: itemCount,
|
||||||
|
textColor: context.colorScheme.onPrimary,
|
||||||
|
backgroundColor: context.colorScheme.primary,
|
||||||
|
child: FloatingActionButton(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
|
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
|
||||||
|
),
|
||||||
|
backgroundColor: context.isDarkTheme
|
||||||
|
? context.colorScheme.surfaceContainer
|
||||||
|
: context.colorScheme.surfaceBright,
|
||||||
|
elevation: 2,
|
||||||
|
onPressed: () {
|
||||||
|
context.pushRoute(const DownloadInfoRoute());
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
alignment: AlignmentDirectional.center,
|
||||||
|
children: [
|
||||||
|
isDownloading
|
||||||
|
? Icon(Icons.downloading_rounded, color: context.colorScheme.primary, size: 28)
|
||||||
|
: Icon(
|
||||||
|
Icons.download_done,
|
||||||
|
color: context.isDarkTheme ? Colors.green[200] : Colors.green[400],
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
if (isDownloading)
|
||||||
|
const SizedBox(
|
||||||
|
height: 31,
|
||||||
|
width: 31,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
value: null, // Indeterminate progress
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.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/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
@ -58,7 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||||
label: "like".t(context: context),
|
label: "like".t(context: context),
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
),
|
),
|
||||||
error: (error, stack) => Text("Error: $error"),
|
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -504,9 +504,9 @@ class _AlbumList extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return const SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(padding: EdgeInsets.all(20.0), child: Text('No albums found')),
|
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -599,9 +599,9 @@ class _AlbumGrid extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return const SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(padding: EdgeInsets.all(20.0), child: Text('No albums found')),
|
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import 'package:hooks_riverpod/hooks_riverpod.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/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
|
||||||
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset?> {
|
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset> {
|
||||||
@override
|
@override
|
||||||
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
|
Future<List<RemoteAsset>> build(BaseAsset asset) {
|
||||||
if (asset == null || asset is! RemoteAsset || asset.stackId == null) {
|
if (asset is! RemoteAsset || asset.stackId == null) {
|
||||||
return const [];
|
return Future.value(const []);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ref.watch(assetServiceProvider).getStack(asset);
|
return ref.watch(assetServiceProvider).getStack(asset);
|
||||||
|
|
@ -14,4 +14,4 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAs
|
||||||
}
|
}
|
||||||
|
|
||||||
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
|
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
|
||||||
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(StackChildrenNotifier.new);
|
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset>(StackChildrenNotifier.new);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
|
||||||
class AssetStackRow extends ConsumerWidget {
|
class AssetStackRow extends ConsumerWidget {
|
||||||
|
|
@ -11,27 +11,25 @@ class AssetStackRow extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
if (asset == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
if (!showControls) {
|
|
||||||
opacity = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
||||||
|
if (stackChildren == null || stackChildren.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 255,
|
ignoring: opacity < 255,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: opacity / 255,
|
opacity: opacity / 255,
|
||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
child: ref
|
child: _StackList(stack: stackChildren),
|
||||||
.watch(stackChildrenNotifier(asset))
|
|
||||||
.when(
|
|
||||||
data: (state) => SizedBox.square(dimension: 80, child: _StackList(stack: state)),
|
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
|
||||||
loading: () => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -44,58 +42,77 @@ class _StackList extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return ListView.builder(
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 30),
|
child: Padding(
|
||||||
itemCount: stack.length,
|
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
|
||||||
itemBuilder: (ctx, index) {
|
child: Row(
|
||||||
final asset = stack[index];
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
return Padding(
|
spacing: 5.0,
|
||||||
padding: const EdgeInsets.only(right: 5),
|
children: List.generate(stack.length, (i) {
|
||||||
child: GestureDetector(
|
final asset = stack[i];
|
||||||
onTap: () {
|
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
|
||||||
ref.read(assetViewerProvider.notifier).setStackIndex(index);
|
}),
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
height: 60,
|
|
||||||
width: 60,
|
|
||||||
decoration: index == ref.watch(assetViewerProvider.select((s) => s.stackIndex))
|
|
||||||
? const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
||||||
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
|
|
||||||
)
|
|
||||||
: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
||||||
border: null,
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
Image(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
image: getThumbnailImageProvider(remoteId: asset.id, size: const Size.square(60)),
|
|
||||||
),
|
|
||||||
if (asset.isVideo)
|
|
||||||
const Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 16,
|
|
||||||
shadows: [
|
|
||||||
Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
class _StackItem extends ConsumerStatefulWidget {
|
||||||
|
final RemoteAsset asset;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
const _StackItem({super.key, required this.asset, required this.index});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_StackItem> createState() => _StackItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StackItemState extends ConsumerState<_StackItem> {
|
||||||
|
void _onTap() {
|
||||||
|
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
|
||||||
|
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const playIcon = Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_circle_outline_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 16,
|
||||||
|
shadows: [Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedDecoration = BoxDecoration(
|
||||||
|
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||||
|
);
|
||||||
|
const unselectedDecoration = BoxDecoration(
|
||||||
|
border: Border.fromBorderSide(BorderSide(color: Colors.grey, width: 0.5)),
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget thumbnail = Thumbnail.fromAsset(asset: widget.asset, size: const Size(60, 40));
|
||||||
|
if (widget.asset.isVideo) {
|
||||||
|
thumbnail = Stack(children: [thumbnail, playIcon]);
|
||||||
|
}
|
||||||
|
thumbnail = ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(10)), child: thumbnail);
|
||||||
|
final isSelected = ref.watch(assetViewerProvider.select((s) => s.stackIndex == widget.index));
|
||||||
|
return SizedBox(
|
||||||
|
width: 60,
|
||||||
|
height: 40,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _onTap,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: isSelected ? selectedDecoration : unselectedDecoration,
|
||||||
|
position: DecorationPosition.foreground,
|
||||||
|
child: thumbnail,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue