mirror of
https://github.com/immich-app/immich.git
synced 2025-12-08 22:01:00 -08:00
Compare commits
53 Commits
feat/notif
...
feat/keybo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e39dc6be | ||
|
|
b29c199491 | ||
|
|
12bb39a111 | ||
|
|
9098717c55 | ||
|
|
8d25f81bec | ||
|
|
52596255c8 | ||
|
|
106effca2e | ||
|
|
9676da27c9 | ||
|
|
3edcb180eb | ||
|
|
9f0b5790af | ||
|
|
e0c2cdddd4 | ||
|
|
74f2c10a5a | ||
|
|
fb97d9f4d9 | ||
|
|
f72bcc8a8f | ||
|
|
46a4dce16b | ||
|
|
62ed5fe27f | ||
|
|
8e3f6cdbbf | ||
|
|
d51b8c1cdf | ||
|
|
698531d6e0 | ||
|
|
44149d187f | ||
|
|
9e3b4ef3db | ||
|
|
ac0d646401 | ||
|
|
664a8fa499 | ||
|
|
3194538817 | ||
|
|
b0d427f8f9 | ||
|
|
02b29046b3 | ||
|
|
c666dc6c67 | ||
|
|
382481735a | ||
|
|
6bb1a9e083 | ||
|
|
3f03a88767 | ||
|
|
328380cfda | ||
|
|
65f29afb0f | ||
|
|
f721a62776 | ||
|
|
c73e3dacea | ||
|
|
78fb815cdb | ||
|
|
d9cddeb0f1 | ||
|
|
c4ff2ea6d5 | ||
|
|
b91b855473 | ||
|
|
7773d6d44f | ||
|
|
2129f889f5 | ||
|
|
221e0ef02f | ||
|
|
0a6b2ad26e | ||
|
|
719bf763e4 | ||
|
|
34bad1ce71 | ||
|
|
6164b027e2 | ||
|
|
d9a13dc8ac | ||
|
|
722dbfa11f | ||
|
|
f8afef0f9d | ||
|
|
3c8df55986 | ||
|
|
47436ad0ce | ||
|
|
9b58d5663a | ||
|
|
b6cebb3ece | ||
|
|
cb7e68a287 |
2
.github/.nvmrc
vendored
2
.github/.nvmrc
vendored
@@ -1 +1 @@
|
||||
22.20.0
|
||||
22.21.0
|
||||
|
||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@@ -31,7 +31,7 @@ documentation:
|
||||
🧠machine-learning:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- machine-learning/app/**
|
||||
- machine-learning/**
|
||||
|
||||
changelog:translation:
|
||||
- head-branch: ['^chore/translations$']
|
||||
|
||||
111
.github/workflows/build-mobile.yml
vendored
111
.github/workflows/build-mobile.yml
vendored
@@ -58,7 +58,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
# Skip when PR from a fork
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
# if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
if: ${{ false }}
|
||||
runs-on: mich
|
||||
|
||||
steps:
|
||||
@@ -154,3 +155,111 @@ jobs:
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
|
||||
|
||||
build-sign-ios:
|
||||
name: Build and sign iOS
|
||||
needs: pre-job
|
||||
permissions:
|
||||
contents: read
|
||||
# Run on main branch or workflow_dispatch
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true && github.ref == 'refs/heads/main' }}
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
cache: true
|
||||
|
||||
- name: Install Flutter dependencies
|
||||
working-directory: ./mobile
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate translation files
|
||||
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Generate platform APIs
|
||||
run: make pigeon
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.2'
|
||||
working-directory: ./mobile/ios
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
cd mobile/ios
|
||||
gem install bundler
|
||||
bundle config set --local path 'vendor/bundle'
|
||||
bundle install
|
||||
|
||||
- name: Create API Key JSON
|
||||
env:
|
||||
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
mkdir -p ~/.appstoreconnect/private_keys
|
||||
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
|
||||
cat > api_key.json << EOF
|
||||
{
|
||||
"key_id": "${API_KEY_ID}",
|
||||
"issuer_id": "${API_KEY_ISSUER_ID}",
|
||||
"key": "$(cat ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8)",
|
||||
"duration": 1200,
|
||||
"in_house": false
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Import Certificate and Provisioning Profile
|
||||
env:
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
||||
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
|
||||
|
||||
- name: Create keychain
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security set-keychain-settings -t 3600 -u build.keychain
|
||||
|
||||
- name: Build and deploy to TestFlight
|
||||
env:
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_NAME: build.keychain
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
working-directory: ./mobile/ios
|
||||
run: bundle exec fastlane release_ci
|
||||
|
||||
- name: Clean up keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain build.keychain || true
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-release-ipa
|
||||
path: mobile/ios/Runner.ipa
|
||||
|
||||
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3.30.8
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3.30.8
|
||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3.30.8
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
17
.github/workflows/docker.yml
vendored
17
.github/workflows/docker.yml
vendored
@@ -116,24 +116,23 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- device: cpu
|
||||
tag-suffix: ''
|
||||
- device: cuda
|
||||
tag-suffix: '-cuda'
|
||||
suffixes: '-cuda'
|
||||
platforms: linux/amd64
|
||||
- device: openvino
|
||||
tag-suffix: '-openvino'
|
||||
suffixes: '-openvino'
|
||||
platforms: linux/amd64
|
||||
- device: armnn
|
||||
tag-suffix: '-armnn'
|
||||
suffixes: '-armnn'
|
||||
platforms: linux/arm64
|
||||
- device: rknn
|
||||
tag-suffix: '-rknn'
|
||||
suffixes: '-rknn'
|
||||
platforms: linux/arm64
|
||||
- device: rocm
|
||||
tag-suffix: '-rocm'
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "mich"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -147,7 +146,7 @@ jobs:
|
||||
dockerfile: machine-learning/Dockerfile
|
||||
platforms: ${{ matrix.platforms }}
|
||||
runner-mapping: ${{ matrix.runner-mapping }}
|
||||
tag-suffix: ${{ matrix.tag-suffix }}
|
||||
suffixes: ${{ matrix.suffixes }}
|
||||
dockerhub-push: ${{ github.event_name == 'release' }}
|
||||
build-args: |
|
||||
DEVICE=${{ matrix.device }}
|
||||
@@ -156,7 +155,7 @@ jobs:
|
||||
name: Build and Push Server
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
2
.github/workflows/docs-build.yml
vendored
2
.github/workflows/docs-build.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
16
.github/workflows/docs-deploy.yml
vendored
16
.github/workflows/docs-deploy.yml
vendored
@@ -185,15 +185,11 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||
working-directory: 'deployment/modules/cloudflare/docs'
|
||||
run: 'mise run tf output -- -json'
|
||||
|
||||
- name: Output Cleaning
|
||||
id: clean
|
||||
env:
|
||||
TG_OUTPUT: ${{ steps.docs-output.outputs.tg_action_output }}
|
||||
run: |
|
||||
CLEANED=$(echo "$TG_OUTPUT" | sed 's|%0A|\n|g ; s|%3C|<|g' | jq -c .)
|
||||
echo "output=$CLEANED" >> $GITHUB_OUTPUT
|
||||
mise run tf output -- -json | jq -r '
|
||||
"projectName=\(.pages_project_name.value)",
|
||||
"subdomain=\(.immich_app_branch_subdomain.value)"
|
||||
' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Publish to Cloudflare Pages
|
||||
# TODO: Action is deprecated
|
||||
@@ -201,7 +197,7 @@ jobs:
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ fromJson(steps.clean.outputs.output).pages_project_name.value }}
|
||||
projectName: ${{ steps.docs-output.outputs.projectName }}
|
||||
workingDirectory: 'docs'
|
||||
directory: 'build'
|
||||
branch: ${{ steps.parameters.outputs.name }}
|
||||
@@ -224,6 +220,6 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
|
||||
body: |
|
||||
📖 Documentation deployed to [${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }}](https://${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }})
|
||||
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
|
||||
emojis: 'rocket'
|
||||
body-include: '<!-- Docs PR URL -->'
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
@@ -62,13 +62,13 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
2
.github/workflows/sdk.yml
vendored
2
.github/workflows/sdk.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
28
.github/workflows/test.yml
vendored
28
.github/workflows/test.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -254,7 +254,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -292,7 +292,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -340,7 +340,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -386,7 +386,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -425,7 +425,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -480,7 +480,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -562,7 +562,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||
# with:
|
||||
@@ -608,7 +608,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -659,7 +659,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -721,7 +721,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -1 +1 @@
|
||||
22.20.0
|
||||
22.21.0
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.18.10",
|
||||
"@types/node": "^22.18.12",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -69,6 +69,6 @@
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.20.0"
|
||||
"node": "22.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('startWatch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should filger out ignored patterns', async () => {
|
||||
it('should filter out ignored patterns', async () => {
|
||||
const testFilePath = path.join(testFolder, 'test.jpg');
|
||||
const ignoredPattern = 'ignored';
|
||||
const ignoredFolder = path.join(testFolder, ignoredPattern);
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface UploadOptionsDto {
|
||||
dryRun?: boolean;
|
||||
skipHash?: boolean;
|
||||
delete?: boolean;
|
||||
deleteDuplicates?: boolean;
|
||||
album?: boolean;
|
||||
albumName?: string;
|
||||
includeHidden?: boolean;
|
||||
@@ -70,10 +71,8 @@ const uploadBatch = async (files: string[], options: UploadOptionsDto) => {
|
||||
console.log(JSON.stringify({ newFiles, duplicates, newAssets }, undefined, 4));
|
||||
}
|
||||
await updateAlbums([...newAssets, ...duplicates], options);
|
||||
await deleteFiles(
|
||||
newAssets.map(({ filepath }) => filepath),
|
||||
options,
|
||||
);
|
||||
|
||||
await deleteFiles(newAssets, duplicates, options);
|
||||
};
|
||||
|
||||
export const startWatch = async (
|
||||
@@ -406,28 +405,46 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const deleteFiles = async (files: string[], options: UploadOptionsDto): Promise<void> => {
|
||||
if (!options.delete) {
|
||||
return;
|
||||
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
|
||||
let fileCount = 0;
|
||||
if (options.delete) {
|
||||
fileCount += uploaded.length;
|
||||
}
|
||||
|
||||
if (options.deleteDuplicates) {
|
||||
fileCount += duplicates.length;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`Would have deleted ${files.length} local asset${s(files.length)}`);
|
||||
console.log(`Would have deleted ${fileCount} local asset${s(fileCount)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Deleting assets that have been uploaded...');
|
||||
|
||||
const deletionProgress = new SingleBar(
|
||||
{ format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
Presets.shades_classic,
|
||||
);
|
||||
deletionProgress.start(files.length, 0);
|
||||
deletionProgress.start(fileCount, 0);
|
||||
|
||||
const chunkDelete = async (files: Asset[]) => {
|
||||
for (const assetBatch of chunk(files, options.concurrency)) {
|
||||
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
|
||||
deletionProgress.update(assetBatch.length);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const assetBatch of chunk(files, options.concurrency)) {
|
||||
await Promise.all(assetBatch.map((input: string) => unlink(input)));
|
||||
deletionProgress.update(assetBatch.length);
|
||||
if (options.delete) {
|
||||
await chunkDelete(uploaded);
|
||||
}
|
||||
|
||||
if (options.deleteDuplicates) {
|
||||
await chunkDelete(duplicates);
|
||||
}
|
||||
} finally {
|
||||
deletionProgress.stop();
|
||||
|
||||
@@ -75,6 +75,11 @@ program
|
||||
.default(false),
|
||||
)
|
||||
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
|
||||
.addOption(
|
||||
new Option('--delete-duplicates', 'Delete local assets that are duplicates (already exist on server)').env(
|
||||
'IMMICH_DELETE_DUPLICATES',
|
||||
),
|
||||
)
|
||||
.addOption(new Option('--no-progress', 'Hide progress bars').env('IMMICH_PROGRESS_BAR').default(true))
|
||||
.addOption(
|
||||
new Option('--watch', 'Watch for changes and upload automatically')
|
||||
|
||||
@@ -122,7 +122,7 @@ services:
|
||||
ports:
|
||||
- 3003:3003
|
||||
volumes:
|
||||
- ../machine-learning:/usr/src/app
|
||||
- ../machine-learning/immich_ml:/usr/src/immich_ml
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996
|
||||
image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
@@ -95,7 +95,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.1.1-ubuntu@sha256:d1da838234ff2de93e0065ee1bf0e66d38f948dcc5d718c25fa6237e14b4424a
|
||||
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ DB_DATA_LOCATION=./postgres
|
||||
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
|
||||
# TZ=Etc/UTC
|
||||
|
||||
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
|
||||
IMMICH_VERSION=release
|
||||
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
|
||||
IMMICH_VERSION=v2
|
||||
|
||||
# Connection secret for postgres. You should change it to a random password
|
||||
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
|
||||
|
||||
@@ -1 +1 @@
|
||||
22.20.0
|
||||
22.21.0
|
||||
|
||||
@@ -103,6 +103,7 @@ Options:
|
||||
-c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY)
|
||||
-j, --json-output Output detailed information in json format (default: false, env: IMMICH_JSON_OUTPUT)
|
||||
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
|
||||
--delete-duplicates Delete local assets that are duplicates (already exist on server) (env: IMMICH_DELETE_DUPLICATES)
|
||||
--no-progress Hide progress bars (env: IMMICH_PROGRESS_BAR)
|
||||
--watch Watch for changes and upload automatically (default: false, env: IMMICH_WATCH_CHANGES)
|
||||
--help display help for command
|
||||
|
||||
@@ -57,6 +57,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.20.0"
|
||||
"node": "22.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
22.20.0
|
||||
22.21.0
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.18.10",
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
@@ -52,6 +52,6 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.20.0"
|
||||
"node": "22.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('/server', () => {
|
||||
importFaces: false,
|
||||
oauth: false,
|
||||
oauthAutoLaunch: false,
|
||||
ocr: false,
|
||||
passwordLogin: true,
|
||||
search: true,
|
||||
sidecar: true,
|
||||
|
||||
@@ -442,6 +442,176 @@ describe(`immich upload`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --delete-duplicates', () => {
|
||||
it('should delete local duplicate files', async () => {
|
||||
const {
|
||||
stderr: firstStderr,
|
||||
stdout: firstStdout,
|
||||
exitCode: firstExitCode,
|
||||
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(firstStderr).toContain('{message}');
|
||||
expect(firstStdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||
);
|
||||
expect(firstExitCode).toBe(0);
|
||||
|
||||
await mkdir(`/tmp/albums/nature`, { recursive: true });
|
||||
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`);
|
||||
|
||||
// Upload with --delete-duplicates flag
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`/tmp/albums/nature/silver_fir.jpg`,
|
||||
'--delete-duplicates',
|
||||
]);
|
||||
|
||||
// Check that the duplicate file was deleted
|
||||
const files = await readdir(`/tmp/albums/nature`);
|
||||
await rm(`/tmp/albums/nature`, { recursive: true });
|
||||
expect(files.length).toBe(0);
|
||||
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Found 0 new files and 1 duplicate'),
|
||||
expect.stringContaining('All assets were already uploaded, nothing to do'),
|
||||
]),
|
||||
);
|
||||
expect(stderr).toContain('{message}');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify no new assets were uploaded
|
||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should have accurate dry run with --delete-duplicates', async () => {
|
||||
const {
|
||||
stderr: firstStderr,
|
||||
stdout: firstStdout,
|
||||
exitCode: firstExitCode,
|
||||
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(firstStderr).toContain('{message}');
|
||||
expect(firstStdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||
);
|
||||
expect(firstExitCode).toBe(0);
|
||||
|
||||
await mkdir(`/tmp/albums/nature`, { recursive: true });
|
||||
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`);
|
||||
|
||||
// Upload with --delete-duplicates and --dry-run flags
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`/tmp/albums/nature/silver_fir.jpg`,
|
||||
'--delete-duplicates',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
// Check that the duplicate file was NOT deleted in dry run mode
|
||||
const files = await readdir(`/tmp/albums/nature`);
|
||||
await rm(`/tmp/albums/nature`, { recursive: true });
|
||||
expect(files.length).toBe(1);
|
||||
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Found 0 new files and 1 duplicate'),
|
||||
expect.stringContaining('Would have deleted 1 local asset'),
|
||||
]),
|
||||
);
|
||||
expect(stderr).toContain('{message}');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify no new assets were uploaded
|
||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should work with both --delete and --delete-duplicates flags', async () => {
|
||||
// First, upload a file to create a duplicate on the server
|
||||
const {
|
||||
stderr: firstStderr,
|
||||
stdout: firstStdout,
|
||||
exitCode: firstExitCode,
|
||||
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(firstStderr).toContain('{message}');
|
||||
expect(firstStdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||
);
|
||||
expect(firstExitCode).toBe(0);
|
||||
|
||||
// Both new and duplicate files
|
||||
await mkdir(`/tmp/albums/nature`, { recursive: true });
|
||||
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`); // duplicate
|
||||
await symlink(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `/tmp/albums/nature/el_torcal_rocks.jpg`); // new
|
||||
|
||||
// Upload with both --delete and --delete-duplicates flags
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`/tmp/albums/nature`,
|
||||
'--delete',
|
||||
'--delete-duplicates',
|
||||
]);
|
||||
|
||||
// Check that both files were deleted (new file due to --delete, duplicate due to --delete-duplicates)
|
||||
const files = await readdir(`/tmp/albums/nature`);
|
||||
await rm(`/tmp/albums/nature`, { recursive: true });
|
||||
expect(files.length).toBe(0);
|
||||
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Found 1 new files and 1 duplicate'),
|
||||
expect.stringContaining('Successfully uploaded 1 new asset'),
|
||||
expect.stringContaining('Deleting assets that have been uploaded'),
|
||||
]),
|
||||
);
|
||||
expect(stderr).toContain('{message}');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify one new asset was uploaded (total should be 2 now)
|
||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should only delete duplicates when --delete-duplicates is used without --delete', async () => {
|
||||
const {
|
||||
stderr: firstStderr,
|
||||
stdout: firstStdout,
|
||||
exitCode: firstExitCode,
|
||||
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(firstStderr).toContain('{message}');
|
||||
expect(firstStdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||
);
|
||||
expect(firstExitCode).toBe(0);
|
||||
|
||||
// Both new and duplicate files
|
||||
await mkdir(`/tmp/albums/nature`, { recursive: true });
|
||||
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`); // duplicate
|
||||
await symlink(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `/tmp/albums/nature/el_torcal_rocks.jpg`); // new
|
||||
|
||||
// Upload with only --delete-duplicates flag
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `/tmp/albums/nature`, '--delete-duplicates']);
|
||||
|
||||
// Check that only the duplicate was deleted, new file should remain
|
||||
const files = await readdir(`/tmp/albums/nature`);
|
||||
await rm(`/tmp/albums/nature`, { recursive: true });
|
||||
expect(files).toEqual(['el_torcal_rocks.jpg']);
|
||||
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Found 1 new files and 1 duplicate'),
|
||||
expect.stringContaining('Successfully uploaded 1 new asset'),
|
||||
]),
|
||||
);
|
||||
expect(stderr).toContain('{message}');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Verify one new asset was uploaded (total should be 2 now)
|
||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --skip-hash', () => {
|
||||
it('should skip hashing', async () => {
|
||||
const filename = `albums/nature/silver_fir.jpg`;
|
||||
|
||||
@@ -59,7 +59,7 @@ test.describe('Asset Viewer Navbar', () => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
await page.keyboard.press('f');
|
||||
await expect(page.locator('#notification-list').getByTestId('message')).toHaveText('Added to favorites');
|
||||
await expect(page.getByText('Added to favorites')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,6 @@ test.describe('Slideshow', () => {
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
|
||||
await page.keyboard.press('f');
|
||||
await expect(page.locator('#notification-list')).not.toBeVisible();
|
||||
await expect(page.getByText('Added to favorites')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
53
i18n/en.json
53
i18n/en.json
@@ -154,6 +154,18 @@
|
||||
"machine_learning_min_detection_score_description": "Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives.",
|
||||
"machine_learning_min_recognized_faces": "Minimum recognized faces",
|
||||
"machine_learning_min_recognized_faces_description": "The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person.",
|
||||
"machine_learning_ocr": "OCR",
|
||||
"machine_learning_ocr_description": "Use machine learning to recognize text in images",
|
||||
"machine_learning_ocr_enabled": "Enable OCR",
|
||||
"machine_learning_ocr_enabled_description": "If disabled, images will not undergo text recognition.",
|
||||
"machine_learning_ocr_max_resolution": "Maximum resolution",
|
||||
"machine_learning_ocr_max_resolution_description": "Previews above this resolution will be resized while preserving aspect ratio. Higher values are more accurate, but take longer to process and use more memory.",
|
||||
"machine_learning_ocr_min_detection_score": "Minimum detection score",
|
||||
"machine_learning_ocr_min_detection_score_description": "Minimum confidence score for text to be detected from 0-1. Lower values will detect more text but may result in false positives.",
|
||||
"machine_learning_ocr_min_recognition_score": "Minimum recognition score",
|
||||
"machine_learning_ocr_min_score_recognition_description": "Minimum confidence score for detected text to be recognized from 0-1. Lower values will recognize more text but may result in false positives.",
|
||||
"machine_learning_ocr_model": "OCR model",
|
||||
"machine_learning_ocr_model_description": "Server models are more accurate than mobile models, but take longer to process and use more memory.",
|
||||
"machine_learning_settings": "Machine Learning Settings",
|
||||
"machine_learning_settings_description": "Manage machine learning features and settings",
|
||||
"machine_learning_smart_search": "Smart Search",
|
||||
@@ -245,6 +257,7 @@
|
||||
"oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided.",
|
||||
"oauth_timeout": "Request Timeout",
|
||||
"oauth_timeout_description": "Timeout for requests in milliseconds",
|
||||
"ocr_job_description": "Use machine learning to recognize text in images",
|
||||
"password_enable_description": "Login with email and password",
|
||||
"password_settings": "Password Login",
|
||||
"password_settings_description": "Manage password login settings",
|
||||
@@ -468,6 +481,7 @@
|
||||
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
|
||||
"api_key_empty": "Your API Key name shouldn't be empty",
|
||||
"api_keys": "API Keys",
|
||||
"app_actions": "App Actions",
|
||||
"app_architecture_variant": "Variant (Architecture)",
|
||||
"app_bar_signout_dialog_content": "Are you sure you want to sign out?",
|
||||
"app_bar_signout_dialog_ok": "Yes",
|
||||
@@ -491,6 +505,7 @@
|
||||
"are_you_sure_to_do_this": "Are you sure you want to do this?",
|
||||
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
||||
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
|
||||
"asset_actions": "Asset Actions",
|
||||
"asset_added_to_album": "Added to album",
|
||||
"asset_adding_to_album": "Adding to album…",
|
||||
"asset_description_updated": "Asset description has been updated",
|
||||
@@ -669,6 +684,8 @@
|
||||
"change_password_description": "This is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
|
||||
"change_password_form_confirm_password": "Confirm Password",
|
||||
"change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
|
||||
"change_password_form_log_out": "Log out all other devices",
|
||||
"change_password_form_log_out_description": "It is recommended to log out of all other devices",
|
||||
"change_password_form_new_password": "New Password",
|
||||
"change_password_form_password_mismatch": "Passwords do not match",
|
||||
"change_password_form_reenter_new_password": "Re-enter New Password",
|
||||
@@ -736,6 +753,7 @@
|
||||
"copy_error": "Copy error",
|
||||
"copy_file_path": "Copy file path",
|
||||
"copy_image": "Copy Image",
|
||||
"copy_image_to_clipboard": "Copy image to clipboard",
|
||||
"copy_link": "Copy link",
|
||||
"copy_link_to_clipboard": "Copy link to clipboard",
|
||||
"copy_password": "Copy password",
|
||||
@@ -776,6 +794,7 @@
|
||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||
"dark": "Dark",
|
||||
"dark_theme": "Toggle dark theme",
|
||||
"date": "Date",
|
||||
"date_after": "Date after",
|
||||
"date_and_time": "Date and Time",
|
||||
"date_before": "Date before",
|
||||
@@ -815,6 +834,7 @@
|
||||
"delete_permanently_action_prompt": "{count} deleted permanently",
|
||||
"delete_shared_link": "Delete shared link",
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"delete_skip_trash": "Delete (skip trash)",
|
||||
"delete_tag": "Delete tag",
|
||||
"delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
|
||||
"delete_user": "Delete user",
|
||||
@@ -890,7 +910,6 @@
|
||||
"edit_tag": "Edit tag",
|
||||
"edit_title": "Edit Title",
|
||||
"edit_user": "Edit user",
|
||||
"edited": "Edited",
|
||||
"editor": "Editor",
|
||||
"editor_close_without_save_prompt": "The changes will not be saved",
|
||||
"editor_close_without_save_title": "Close editor?",
|
||||
@@ -1085,6 +1104,7 @@
|
||||
"features_setting_description": "Manage the app features",
|
||||
"file_name": "File name",
|
||||
"file_name_or_extension": "File name or extension",
|
||||
"file_size": "File size",
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
@@ -1093,6 +1113,8 @@
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
"focus_next": "Focus next",
|
||||
"focus_previous": "Focus previous",
|
||||
"folder": "Folder",
|
||||
"folder_not_found": "Folder not found",
|
||||
"folders": "Folders",
|
||||
@@ -1104,9 +1126,11 @@
|
||||
"general": "General",
|
||||
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
|
||||
"get_help": "Get Help",
|
||||
"get_my_immich_link": "Get My Immich Link",
|
||||
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
|
||||
"getting_started": "Getting Started",
|
||||
"go_back": "Go back",
|
||||
"go_to_date": "Go to date",
|
||||
"go_to_folder": "Go to folder",
|
||||
"go_to_search": "Go to search",
|
||||
"gps": "GPS",
|
||||
@@ -1248,6 +1272,7 @@
|
||||
"local_media_summary": "Local Media Summary",
|
||||
"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",
|
||||
"location": "Location",
|
||||
"location_permission": "Location permission",
|
||||
"location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current Wi-Fi network's name",
|
||||
"location_picker_choose_on_map": "Choose on map",
|
||||
@@ -1359,10 +1384,13 @@
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"more": "More",
|
||||
"move": "Move",
|
||||
"move_left": "Move left",
|
||||
"move_off_locked_folder": "Move out of locked folder",
|
||||
"move_right": "Move right",
|
||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||
"move_to_locked_folder": "Move to locked folder",
|
||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||
"move_to_trash": "Move to trash",
|
||||
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
|
||||
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
|
||||
"moved_to_trash": "Moved to trash",
|
||||
@@ -1374,6 +1402,7 @@
|
||||
"name_or_nickname": "Name or nickname",
|
||||
"navigate": "Navigate",
|
||||
"navigate_to_time": "Navigate to Time",
|
||||
"navigation": "Navigation",
|
||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
||||
"network_requirements": "Network Requirements",
|
||||
@@ -1393,7 +1422,10 @@
|
||||
"new_version_available": "NEW VERSION AVAILABLE",
|
||||
"newest_first": "Newest first",
|
||||
"next": "Next",
|
||||
"next_day": "Next day",
|
||||
"next_memory": "Next memory",
|
||||
"next_month": "Next month",
|
||||
"next_year": "Next year",
|
||||
"no": "No",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
@@ -1419,6 +1451,7 @@
|
||||
"no_results": "No results",
|
||||
"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_shortcuts": "No shortcuts",
|
||||
"no_uploads_in_progress": "No uploads in progress",
|
||||
"not_available": "N/A",
|
||||
"not_in_any_album": "Not in any album",
|
||||
@@ -1436,6 +1469,7 @@
|
||||
"oauth": "OAuth",
|
||||
"obtainium_configurator": "Obtainium Configurator",
|
||||
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
|
||||
"ocr": "OCR",
|
||||
"official_immich_resources": "Official Immich Resources",
|
||||
"offline": "Offline",
|
||||
"offset": "Offset",
|
||||
@@ -1540,6 +1574,9 @@
|
||||
"play_memories": "Play memories",
|
||||
"play_motion_photo": "Play Motion Photo",
|
||||
"play_or_pause_video": "Play or pause video",
|
||||
"play_original_video": "Play original video",
|
||||
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
|
||||
"play_transcoded_video": "Play transcoded video",
|
||||
"please_auth_to_access": "Please authenticate to access",
|
||||
"port": "Port",
|
||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||
@@ -1548,11 +1585,14 @@
|
||||
"preset": "Preset",
|
||||
"preview": "Preview",
|
||||
"previous": "Previous",
|
||||
"previous_day": "Previous day",
|
||||
"previous_memory": "Previous memory",
|
||||
"previous_month": "Previous month",
|
||||
"previous_or_next_day": "Day forward/back",
|
||||
"previous_or_next_month": "Month forward/back",
|
||||
"previous_or_next_photo": "Photo forward/back",
|
||||
"previous_or_next_year": "Year forward/back",
|
||||
"previous_year": "Previous year",
|
||||
"primary": "Primary",
|
||||
"privacy": "Privacy",
|
||||
"profile": "Profile",
|
||||
@@ -1598,6 +1638,7 @@
|
||||
"purchase_settings_server_activated": "The server product key is managed by the admin",
|
||||
"query_asset_id": "Query Asset ID",
|
||||
"queue_status": "Queuing {count}/{total}",
|
||||
"quick_actions": "Quick Actions",
|
||||
"rating": "Star rating",
|
||||
"rating_clear": "Clear rating",
|
||||
"rating_count": "{count, plural, one {# star} other {# stars}}",
|
||||
@@ -1676,6 +1717,7 @@
|
||||
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
|
||||
"reset_sqlite_success": "Successfully reset the SQLite database",
|
||||
"reset_to_default": "Reset to default",
|
||||
"resolution": "Resolution",
|
||||
"resolve_duplicates": "Resolve duplicates",
|
||||
"resolved_all_duplicates": "Resolved all duplicates",
|
||||
"restore": "Restore",
|
||||
@@ -1694,6 +1736,7 @@
|
||||
"running": "Running",
|
||||
"save": "Save",
|
||||
"save_to_gallery": "Save to gallery",
|
||||
"saved": "Saved",
|
||||
"saved_api_key": "Saved API Key",
|
||||
"saved_profile": "Saved profile",
|
||||
"saved_settings": "Saved settings",
|
||||
@@ -1710,6 +1753,9 @@
|
||||
"search_by_description_example": "Hiking day in Sapa",
|
||||
"search_by_filename": "Search by file name or extension",
|
||||
"search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
|
||||
"search_by_ocr": "Search by OCR",
|
||||
"search_by_ocr_example": "Latte",
|
||||
"search_camera_lens_model": "Search lens model...",
|
||||
"search_camera_make": "Search camera make...",
|
||||
"search_camera_model": "Search camera model...",
|
||||
"search_city": "Search city...",
|
||||
@@ -1726,6 +1772,7 @@
|
||||
"search_filter_location_title": "Select location",
|
||||
"search_filter_media_type": "Media Type",
|
||||
"search_filter_media_type_title": "Select media type",
|
||||
"search_filter_ocr": "Search by OCR",
|
||||
"search_filter_people_title": "Select people",
|
||||
"search_for": "Search for",
|
||||
"search_for_existing_person": "Search for existing person",
|
||||
@@ -1779,6 +1826,7 @@
|
||||
"selected": "Selected",
|
||||
"selected_count": "{count, plural, other {# selected}}",
|
||||
"selected_gps_coordinates": "Selected GPS Coordinates",
|
||||
"selection": "Selection",
|
||||
"send_message": "Send message",
|
||||
"send_welcome_email": "Send welcome email",
|
||||
"server_endpoint": "Server Endpoint",
|
||||
@@ -1942,6 +1990,7 @@
|
||||
"start": "Start",
|
||||
"start_date": "Start date",
|
||||
"start_date_before_end_date": "Start date must be before end date",
|
||||
"start_slideshow": "Start slideshow",
|
||||
"state": "State",
|
||||
"status": "Status",
|
||||
"stop_casting": "Stop casting",
|
||||
@@ -1998,6 +2047,7 @@
|
||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||
"they_will_be_merged_together": "They will be merged together",
|
||||
"third_party_resources": "Third-Party Resources",
|
||||
"time": "Time",
|
||||
"time_based_memories": "Time-based memories",
|
||||
"timeline": "Timeline",
|
||||
"timezone": "Timezone",
|
||||
@@ -2115,6 +2165,7 @@
|
||||
"videos": "Videos",
|
||||
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
|
||||
"view": "View",
|
||||
"view_actions": "View Actions",
|
||||
"view_album": "View Album",
|
||||
"view_all": "View All",
|
||||
"view_all_users": "View all users",
|
||||
|
||||
@@ -141,7 +141,7 @@ FROM prod-${DEVICE} AS prod
|
||||
ARG DEVICE
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tini $(if ! [ "$DEVICE" = "openvino" ] && ! [ "$DEVICE" = "rocm" ]; then echo "libmimalloc2.0"; fi) && \
|
||||
apt-get install -y --no-install-recommends tini ccache libgl1 libglib2.0-0 libgomp1 $(if ! [ "$DEVICE" = "openvino" ] && ! [ "$DEVICE" = "rocm" ]; then echo "libmimalloc2.0"; fi) && \
|
||||
apt-get autoremove -yqq && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -41,6 +41,7 @@ class PreloadModelData(BaseModel):
|
||||
|
||||
class MaxBatchSize(BaseModel):
|
||||
facial_recognition: int | None = None
|
||||
text_recognition: int | None = None
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -183,7 +183,9 @@ async def run_inference(payload: Image | str, entries: InferenceEntries) -> Infe
|
||||
response: InferenceResponse = {}
|
||||
|
||||
async def _run_inference(entry: InferenceEntry) -> None:
|
||||
model = await model_cache.get(entry["name"], entry["type"], entry["task"], ttl=settings.model_ttl)
|
||||
model = await model_cache.get(
|
||||
entry["name"], entry["type"], entry["task"], ttl=settings.model_ttl, **entry["options"]
|
||||
)
|
||||
inputs = [payload]
|
||||
for dep in model.depends:
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Any
|
||||
from immich_ml.models.base import InferenceModel
|
||||
from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder
|
||||
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
||||
from immich_ml.models.ocr.detection import TextDetector
|
||||
from immich_ml.models.ocr.recognition import TextRecognizer
|
||||
from immich_ml.schemas import ModelSource, ModelTask, ModelType
|
||||
|
||||
from .constants import get_model_source
|
||||
@@ -28,6 +30,12 @@ def get_model_class(model_name: str, model_type: ModelType, model_task: ModelTas
|
||||
case ModelSource.INSIGHTFACE, ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION:
|
||||
return FaceRecognizer
|
||||
|
||||
case ModelSource.PADDLE, ModelType.DETECTION, ModelTask.OCR:
|
||||
return TextDetector
|
||||
|
||||
case ModelSource.PADDLE, ModelType.RECOGNITION, ModelTask.OCR:
|
||||
return TextRecognizer
|
||||
|
||||
case _:
|
||||
raise ValueError(f"Unknown model combination: {source}, {model_type}, {model_task}")
|
||||
|
||||
|
||||
@@ -38,9 +38,8 @@ class InferenceModel(ABC):
|
||||
|
||||
def download(self) -> None:
|
||||
if not self.cached:
|
||||
log.info(
|
||||
f"Downloading {self.model_type.replace('-', ' ')} model '{self.model_name}'. This may take a while."
|
||||
)
|
||||
model_type = self.model_type.replace("-", " ")
|
||||
log.info(f"Downloading {model_type} model '{self.model_name}' to {self.model_path}. This may take a while.")
|
||||
self._download()
|
||||
|
||||
def load(self) -> None:
|
||||
@@ -58,7 +57,7 @@ class InferenceModel(ABC):
|
||||
self.load()
|
||||
if model_kwargs:
|
||||
self.configure(**model_kwargs)
|
||||
return self._predict(*inputs, **model_kwargs)
|
||||
return self._predict(*inputs)
|
||||
|
||||
@abstractmethod
|
||||
def _predict(self, *inputs: Any, **model_kwargs: Any) -> Any: ...
|
||||
|
||||
@@ -19,7 +19,7 @@ class BaseCLIPTextualEncoder(InferenceModel):
|
||||
depends = []
|
||||
identity = (ModelType.TEXTUAL, ModelTask.SEARCH)
|
||||
|
||||
def _predict(self, inputs: str, language: str | None = None, **kwargs: Any) -> str:
|
||||
def _predict(self, inputs: str, language: str | None = None) -> str:
|
||||
tokens = self.tokenize(inputs, language=language)
|
||||
res: NDArray[np.float32] = self.session.run(None, tokens)[0][0]
|
||||
return serialize_np_array(res)
|
||||
|
||||
@@ -26,7 +26,7 @@ class BaseCLIPVisualEncoder(InferenceModel):
|
||||
depends = []
|
||||
identity = (ModelType.VISUAL, ModelTask.SEARCH)
|
||||
|
||||
def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> str:
|
||||
def _predict(self, inputs: Image.Image | bytes) -> str:
|
||||
image = decode_pil(inputs)
|
||||
res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0]
|
||||
return serialize_np_array(res)
|
||||
|
||||
@@ -75,6 +75,11 @@ _INSIGHTFACE_MODELS = {
|
||||
}
|
||||
|
||||
|
||||
_PADDLE_MODELS = {
|
||||
"PP-OCRv5_server",
|
||||
"PP-OCRv5_mobile",
|
||||
}
|
||||
|
||||
SUPPORTED_PROVIDERS = [
|
||||
"CUDAExecutionProvider",
|
||||
"ROCMExecutionProvider",
|
||||
@@ -159,4 +164,7 @@ def get_model_source(model_name: str) -> ModelSource | None:
|
||||
if cleaned_name in _OPENCLIP_MODELS:
|
||||
return ModelSource.OPENCLIP
|
||||
|
||||
if cleaned_name in _PADDLE_MODELS:
|
||||
return ModelSource.PADDLE
|
||||
|
||||
return None
|
||||
|
||||
@@ -24,7 +24,7 @@ class FaceDetector(InferenceModel):
|
||||
|
||||
return session
|
||||
|
||||
def _predict(self, inputs: NDArray[np.uint8] | bytes, **kwargs: Any) -> FaceDetectionOutput:
|
||||
def _predict(self, inputs: NDArray[np.uint8] | bytes) -> FaceDetectionOutput:
|
||||
inputs = decode_cv2(inputs)
|
||||
|
||||
bboxes, landmarks = self._detect(inputs)
|
||||
|
||||
@@ -44,7 +44,7 @@ class FaceRecognizer(InferenceModel):
|
||||
return session
|
||||
|
||||
def _predict(
|
||||
self, inputs: NDArray[np.uint8] | bytes | Image.Image, faces: FaceDetectionOutput, **kwargs: Any
|
||||
self, inputs: NDArray[np.uint8] | bytes | Image.Image, faces: FaceDetectionOutput
|
||||
) -> FacialRecognitionOutput:
|
||||
if faces["boxes"].shape[0] == 0:
|
||||
return []
|
||||
|
||||
86
machine-learning/immich_ml/models/ocr/detection.py
Normal file
86
machine-learning/immich_ml/models/ocr/detection.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from rapidocr.ch_ppocr_det import TextDetector as RapidTextDetector
|
||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||
|
||||
from immich_ml.config import log
|
||||
from immich_ml.models.base import InferenceModel
|
||||
from immich_ml.models.transforms import decode_cv2
|
||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||
from immich_ml.sessions.ort import OrtSession
|
||||
|
||||
from .schemas import OcrOptions, TextDetectionOutput
|
||||
|
||||
|
||||
class TextDetector(InferenceModel):
|
||||
depends = []
|
||||
identity = (ModelType.DETECTION, ModelTask.OCR)
|
||||
|
||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
||||
self.max_resolution = 736
|
||||
self.min_score = 0.5
|
||||
self.score_mode = "fast"
|
||||
self._empty: TextDetectionOutput = {
|
||||
"image": np.empty(0, dtype=np.float32),
|
||||
"boxes": np.empty(0, dtype=np.float32),
|
||||
"scores": np.empty(0, dtype=np.float32),
|
||||
}
|
||||
|
||||
def _download(self) -> None:
|
||||
model_info = InferSession.get_model_url(
|
||||
FileInfo(
|
||||
engine_type=EngineType.ONNXRUNTIME,
|
||||
ocr_version=OCRVersion.PPOCRV5,
|
||||
task_type=TaskType.DET,
|
||||
lang_type=LangDet.CH,
|
||||
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
|
||||
)
|
||||
)
|
||||
download_params = DownloadFileInput(
|
||||
file_url=model_info["model_dir"],
|
||||
sha256=model_info["SHA256"],
|
||||
save_path=self.model_path,
|
||||
logger=log,
|
||||
)
|
||||
DownloadFile.run(download_params)
|
||||
|
||||
def _load(self) -> ModelSession:
|
||||
# TODO: support other runtime sessions
|
||||
session = OrtSession(self.model_path)
|
||||
self.model = RapidTextDetector(
|
||||
OcrOptions(
|
||||
session=session.session,
|
||||
limit_side_len=self.max_resolution,
|
||||
limit_type="min",
|
||||
box_thresh=self.min_score,
|
||||
score_mode=self.score_mode,
|
||||
)
|
||||
)
|
||||
return session
|
||||
|
||||
def _predict(self, inputs: bytes | Image.Image) -> TextDetectionOutput:
|
||||
results = self.model(decode_cv2(inputs))
|
||||
if results.boxes is None or results.scores is None or results.img is None:
|
||||
return self._empty
|
||||
return {
|
||||
"image": results.img,
|
||||
"boxes": np.array(results.boxes, dtype=np.float32),
|
||||
"scores": np.array(results.scores, dtype=np.float32),
|
||||
}
|
||||
|
||||
def configure(self, **kwargs: Any) -> None:
|
||||
if (max_resolution := kwargs.get("maxResolution")) is not None:
|
||||
self.max_resolution = max_resolution
|
||||
self.model.limit_side_len = max_resolution
|
||||
if (min_score := kwargs.get("minScore")) is not None:
|
||||
self.min_score = min_score
|
||||
self.model.postprocess_op.box_thresh = min_score
|
||||
if (score_mode := kwargs.get("scoreMode")) is not None:
|
||||
self.score_mode = score_mode
|
||||
self.model.postprocess_op.score_mode = score_mode
|
||||
117
machine-learning/immich_ml/models/ocr/recognition.py
Normal file
117
machine-learning/immich_ml/models/ocr/recognition.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL.Image import Image
|
||||
from rapidocr.ch_ppocr_rec import TextRecInput
|
||||
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
|
||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.typings import EngineType, LangRec, OCRVersion, TaskType
|
||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||
|
||||
from immich_ml.config import log, settings
|
||||
from immich_ml.models.base import InferenceModel
|
||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||
from immich_ml.sessions.ort import OrtSession
|
||||
|
||||
from .schemas import OcrOptions, TextDetectionOutput, TextRecognitionOutput
|
||||
|
||||
|
||||
class TextRecognizer(InferenceModel):
|
||||
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
||||
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
||||
|
||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||
self.min_score = model_kwargs.get("minScore", 0.9)
|
||||
self._empty: TextRecognitionOutput = {
|
||||
"box": np.empty(0, dtype=np.float32),
|
||||
"boxScore": np.empty(0, dtype=np.float32),
|
||||
"text": [],
|
||||
"textScore": np.empty(0, dtype=np.float32),
|
||||
}
|
||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
||||
|
||||
def _download(self) -> None:
|
||||
model_info = InferSession.get_model_url(
|
||||
FileInfo(
|
||||
engine_type=EngineType.ONNXRUNTIME,
|
||||
ocr_version=OCRVersion.PPOCRV5,
|
||||
task_type=TaskType.REC,
|
||||
lang_type=LangRec.CH,
|
||||
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
|
||||
)
|
||||
)
|
||||
download_params = DownloadFileInput(
|
||||
file_url=model_info["model_dir"],
|
||||
sha256=model_info["SHA256"],
|
||||
save_path=self.model_path,
|
||||
logger=log,
|
||||
)
|
||||
DownloadFile.run(download_params)
|
||||
|
||||
def _load(self) -> ModelSession:
|
||||
# TODO: support other runtimes
|
||||
session = OrtSession(self.model_path)
|
||||
self.model = RapidTextRecognizer(
|
||||
OcrOptions(
|
||||
session=session.session,
|
||||
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
)
|
||||
)
|
||||
return session
|
||||
|
||||
def _predict(self, _: Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
|
||||
boxes, img, box_scores = texts["boxes"], texts["image"], texts["scores"]
|
||||
if boxes.shape[0] == 0:
|
||||
return self._empty
|
||||
rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes)))
|
||||
if rec.txts is None:
|
||||
return self._empty
|
||||
|
||||
height, width = img.shape[0:2]
|
||||
boxes[:, :, 0] /= width
|
||||
boxes[:, :, 1] /= height
|
||||
|
||||
text_scores = np.array(rec.scores)
|
||||
valid_text_score_idx = text_scores > self.min_score
|
||||
valid_score_idx_list = valid_text_score_idx.tolist()
|
||||
return {
|
||||
"box": boxes.reshape(-1, 8)[valid_text_score_idx].reshape(-1),
|
||||
"text": [rec.txts[i] for i in range(len(rec.txts)) if valid_score_idx_list[i]],
|
||||
"boxScore": box_scores[valid_text_score_idx],
|
||||
"textScore": text_scores[valid_text_score_idx],
|
||||
}
|
||||
|
||||
def get_crop_img_list(self, img: NDArray[np.float32], boxes: NDArray[np.float32]) -> list[NDArray[np.float32]]:
|
||||
img_crop_width = np.maximum(
|
||||
np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1)
|
||||
).astype(np.int32)
|
||||
img_crop_height = np.maximum(
|
||||
np.linalg.norm(boxes[:, 0] - boxes[:, 3], axis=1), np.linalg.norm(boxes[:, 1] - boxes[:, 2], axis=1)
|
||||
).astype(np.int32)
|
||||
pts_std = np.zeros((img_crop_width.shape[0], 4, 2), dtype=np.float32)
|
||||
pts_std[:, 1:3, 0] = img_crop_width[:, None]
|
||||
pts_std[:, 2:4, 1] = img_crop_height[:, None]
|
||||
|
||||
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1).tolist()
|
||||
imgs: list[NDArray[np.float32]] = []
|
||||
for box, pts_std, dst_size in zip(list(boxes), list(pts_std), img_crop_sizes):
|
||||
M = cv2.getPerspectiveTransform(box, pts_std)
|
||||
dst_img: NDArray[np.float32] = cv2.warpPerspective(
|
||||
img,
|
||||
M,
|
||||
dst_size,
|
||||
borderMode=cv2.BORDER_REPLICATE,
|
||||
flags=cv2.INTER_CUBIC,
|
||||
) # type: ignore
|
||||
dst_height, dst_width = dst_img.shape[0:2]
|
||||
if dst_height * 1.0 / dst_width >= 1.5:
|
||||
dst_img = np.rot90(dst_img)
|
||||
imgs.append(dst_img)
|
||||
return imgs
|
||||
|
||||
def configure(self, **kwargs: Any) -> None:
|
||||
self.min_score = kwargs.get("minScore", self.min_score)
|
||||
28
machine-learning/immich_ml/models/ocr/schemas.py
Normal file
28
machine-learning/immich_ml/models/ocr/schemas.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import Any, Iterable
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from rapidocr.utils.typings import EngineType, LangRec
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class TextDetectionOutput(TypedDict):
|
||||
image: npt.NDArray[np.float32]
|
||||
boxes: npt.NDArray[np.float32]
|
||||
scores: npt.NDArray[np.float32]
|
||||
|
||||
|
||||
class TextRecognitionOutput(TypedDict):
|
||||
box: npt.NDArray[np.float32]
|
||||
boxScore: npt.NDArray[np.float32]
|
||||
text: Iterable[str]
|
||||
textScore: npt.NDArray[np.float32]
|
||||
|
||||
|
||||
# RapidOCR expects `engine_type`, `lang_type`, and `font_path` to be attributes
|
||||
class OcrOptions(dict[str, Any]):
|
||||
def __init__(self, **options: Any) -> None:
|
||||
super().__init__(**options)
|
||||
self.engine_type = EngineType.ONNXRUNTIME
|
||||
self.lang_type = LangRec.CH
|
||||
self.font_path = None
|
||||
@@ -23,6 +23,7 @@ class BoundingBox(TypedDict):
|
||||
class ModelTask(StrEnum):
|
||||
FACIAL_RECOGNITION = "facial-recognition"
|
||||
SEARCH = "clip"
|
||||
OCR = "ocr"
|
||||
|
||||
|
||||
class ModelType(StrEnum):
|
||||
@@ -42,6 +43,7 @@ class ModelSource(StrEnum):
|
||||
INSIGHTFACE = "insightface"
|
||||
MCLIP = "mclip"
|
||||
OPENCLIP = "openclip"
|
||||
PADDLE = "paddle"
|
||||
|
||||
|
||||
ModelIdentity = tuple[ModelType, ModelTask]
|
||||
|
||||
@@ -14,6 +14,8 @@ from ..config import log, settings
|
||||
|
||||
|
||||
class OrtSession:
|
||||
session: ort.InferenceSession
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: Path | str,
|
||||
|
||||
@@ -22,6 +22,8 @@ dependencies = [
|
||||
"rich>=13.4.2",
|
||||
"tokenizers>=0.15.0,<1.0",
|
||||
"uvicorn[standard]>=0.22.0,<1.0",
|
||||
"setuptools>=78.1.0",
|
||||
"rapidocr>=3.1.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
3470
machine-learning/uv.lock
generated
3470
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
node = "22.20.0"
|
||||
flutter = "3.35.6"
|
||||
node = "22.21.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.18.1"
|
||||
terragrunt = "0.91.2"
|
||||
opentofu = "1.10.6"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"flutter": "3.35.6"
|
||||
"flutter": "3.35.7"
|
||||
}
|
||||
2
mobile/.vscode/settings.json
vendored
2
mobile/.vscode/settings.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.6",
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.7",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [120]
|
||||
|
||||
@@ -17,7 +17,7 @@ linter:
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at
|
||||
# https://dart-lang.github.io/linter/lints/index.html.
|
||||
# https://dart.dev/tools/linter-rules
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
@@ -28,6 +28,7 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
unawaited_futures: true
|
||||
use_build_context_synchronously: false
|
||||
require_trailing_commas: true
|
||||
unrelated_type_equality_checks: true
|
||||
@@ -46,6 +47,8 @@ analyzer:
|
||||
# TODO: Re-enable after upgrading custom_lint
|
||||
# plugins:
|
||||
# - custom_lint
|
||||
errors:
|
||||
unawaited_futures: warning
|
||||
|
||||
custom_lint:
|
||||
debug: true
|
||||
@@ -152,160 +155,6 @@ dart_code_metrics:
|
||||
# - avoid-passing-async-when-sync-expected
|
||||
# - avoid-throw-in-catch-block
|
||||
- avoid-unused-parameters
|
||||
# - avoid-unnecessary-type-assertions
|
||||
# - avoid-unnecessary-type-casts
|
||||
# - avoid-unrelated-type-assertions
|
||||
# - avoid-unrelated-type-casts
|
||||
# - no-empty-block
|
||||
# - no-equal-then-else
|
||||
# - prefer-correct-test-file-name
|
||||
- prefer-const-border-radius
|
||||
# - prefer-match-file-name
|
||||
# - prefer-return-await
|
||||
# - avoid-self-assignment
|
||||
# - avoid-self-compare
|
||||
# - avoid-shadowing
|
||||
# - prefer-iterable-of
|
||||
# - no-equal-switch-case
|
||||
# - no-equal-conditions
|
||||
# - avoid-equal-expressions
|
||||
# - avoid-missed-calls
|
||||
# - avoid-unnecessary-negations
|
||||
# - avoid-unused-generics
|
||||
# - function-always-returns-null
|
||||
# - avoid-throw-objects-without-tostring
|
||||
# - avoid-unsafe-collection-methods
|
||||
# - prefer-wildcard-pattern
|
||||
# - no-equal-switch-expression-cases
|
||||
# - avoid-future-tostring
|
||||
# - avoid-unassigned-late-fields
|
||||
# - avoid-nested-futures
|
||||
# - avoid-generics-shadowing
|
||||
# - prefer-parentheses-with-if-null
|
||||
# - no-equal-nested-conditions
|
||||
# - avoid-shadowed-extension-methods
|
||||
# - avoid-unnecessary-conditionals
|
||||
# - avoid-double-slash-imports
|
||||
# - avoid-map-keys-contains
|
||||
# - prefer-correct-json-casts
|
||||
# - avoid-duplicate-mixins
|
||||
# - avoid-nullable-interpolation
|
||||
# - avoid-unused-instances
|
||||
# - prefer-correct-for-loop-increment
|
||||
# - prefer-public-exception-classes
|
||||
# - avoid-uncaught-future-errors
|
||||
# - always-remove-listener
|
||||
# - avoid-unnecessary-setstate
|
||||
# - check-for-equals-in-render-object-setters
|
||||
# - consistent-update-render-object
|
||||
# - use-setstate-synchronously
|
||||
# - avoid-incomplete-copy-with
|
||||
# - proper-super-calls
|
||||
# - dispose-fields
|
||||
# - avoid-empty-setstate
|
||||
# - avoid-state-constructors
|
||||
# - avoid-recursive-widget-calls
|
||||
# - avoid-missing-image-alt
|
||||
# - avoid-passing-self-as-argument
|
||||
# - avoid-unnecessary-if
|
||||
# - avoid-unconditional-break
|
||||
# - avoid-referencing-discarded-variables
|
||||
# - avoid-unnecessary-local-late
|
||||
# - avoid-wildcard-cases-with-enums
|
||||
# - match-getter-setter-field-names
|
||||
# - avoid-accessing-collections-by-constant-index
|
||||
# - prefer-unique-test-names
|
||||
# - avoid-duplicate-cascades
|
||||
# - prefer-specific-cases-first
|
||||
# - avoid-duplicate-switch-case-conditions
|
||||
# - prefer-explicit-function-type
|
||||
# - avoid-misused-test-matchers
|
||||
# - avoid-duplicate-test-assertions
|
||||
# - prefer-switch-with-enums
|
||||
# - prefer-any-or-every
|
||||
# - avoid-duplicate-map-keys
|
||||
# - avoid-nullable-tostring
|
||||
# - avoid-undisposed-instances
|
||||
# - avoid-duplicate-initializers
|
||||
# - avoid-unassigned-stream-subscriptions
|
||||
# - avoid-empty-test-groups
|
||||
# - avoid-not-encodable-in-to-json
|
||||
# - avoid-contradictory-expressions
|
||||
# - avoid-excessive-expressions
|
||||
# - prefer-private-extension-type-field
|
||||
# - avoid-renaming-representation-getters
|
||||
# - avoid-empty-spread
|
||||
# - avoid-unnecessary-gesture-detector
|
||||
# - avoid-missing-completer-stack-trace
|
||||
# - avoid-casting-to-extension-type
|
||||
# - prefer-overriding-parent-equality
|
||||
# - avoid-missing-controller
|
||||
# - avoid-unknown-pragma
|
||||
# - avoid-conditions-with-boolean-literals
|
||||
# - avoid-multi-assignment
|
||||
# - avoid-collection-equality-checks
|
||||
# - avoid-only-rethrow
|
||||
# - avoid-incorrect-image-opacity
|
||||
# - avoid-misused-set-literals
|
||||
# - dispose-class-fields
|
||||
# - avoid-suspicious-super-overrides
|
||||
# - avoid-assignments-as-conditions
|
||||
# - avoid-unused-assignment
|
||||
# - avoid-unnecessary-overrides
|
||||
# - avoid-implicitly-nullable-extension-types
|
||||
# Enable with the next release
|
||||
# - avoid-late-final-reassignment
|
||||
# - avoid-duplicate-constant-values
|
||||
# - function-always-returns-same-value
|
||||
# - avoid-flexible-outside-flex
|
||||
# - avoid-unnecessary-patterns
|
||||
# - use-closest-build-context
|
||||
# - avoid-commented-out-code
|
||||
# - avoid-recursive-tostring
|
||||
# - avoid-enum-values-by-index
|
||||
# - avoid-constant-assert-conditions
|
||||
# - avoid-inconsistent-digit-separators
|
||||
# - pass-existing-future-to-future-builder
|
||||
# - pass-existing-stream-to-stream-builder
|
||||
|
||||
# Code simplification
|
||||
# - avoid-redundant-async
|
||||
# - avoid-redundant-else
|
||||
# - avoid-unnecessary-nullable-return-type
|
||||
# - avoid-redundant-pragma-inline
|
||||
# - avoid-nested-records
|
||||
# - avoid-redundant-positional-field-name
|
||||
# - avoid-explicit-pattern-field-name
|
||||
# - prefer-simpler-patterns-null-check
|
||||
# - avoid-unnecessary-return
|
||||
# - avoid-duplicate-patterns
|
||||
# - avoid-keywords-in-wildcard-pattern
|
||||
# - avoid-unnecessary-futures
|
||||
# - avoid-unnecessary-reassignment
|
||||
# - avoid-unnecessary-call
|
||||
# - avoid-unnecessary-stateful-widgets
|
||||
# - prefer-dedicated-media-query-methods
|
||||
# - avoid-unnecessary-overrides-in-state
|
||||
# - move-variable-closer-to-its-usage
|
||||
# - avoid-nullable-parameters-with-default-values
|
||||
# - prefer-null-aware-spread
|
||||
# - avoid-inferrable-type-arguments
|
||||
# - avoid-unnecessary-super
|
||||
# - avoid-unnecessary-collections
|
||||
# - avoid-unnecessary-extends
|
||||
# - avoid-unnecessary-enum-arguments
|
||||
# - prefer-contains
|
||||
# Enable with the next release
|
||||
# - prefer-simpler-boolean-expressions
|
||||
# - prefer-spacing
|
||||
# - avoid-unnecessary-continue
|
||||
# - avoid-unnecessary-compare-to
|
||||
|
||||
# Style
|
||||
# - prefer-trailing-comma
|
||||
# - unnecessary-trailing-comma
|
||||
- prefer-declaring-const-constructor
|
||||
# - prefer-single-widget-per-file
|
||||
- prefer-switch-expression
|
||||
# - prefer-prefixed-global-constants
|
||||
# - prefer-correct-callback-field-name
|
||||
|
||||
@@ -9,6 +9,7 @@ import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||
import app.alextran.immich.background.BackgroundWorkerLockApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import app.alextran.immich.images.ThumbnailApi
|
||||
import app.alextran.immich.images.ThumbnailsImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
@@ -42,6 +43,14 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
}
|
||||
|
||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||
val nativeApi =
|
||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||
nativeApi?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
private const val TAG = "BackgroundEngineLock"
|
||||
|
||||
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
|
||||
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, ImmichPlugin() {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
companion object {
|
||||
@@ -41,12 +42,14 @@ class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterP
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
super.onAttachedToEngine(binding)
|
||||
checkAndEnforceBackgroundLock(binding.applicationContext)
|
||||
engineCount.incrementAndGet()
|
||||
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
super.onDetachedFromEngine(binding)
|
||||
engineCount.decrementAndGet()
|
||||
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||
}
|
||||
|
||||
@@ -190,6 +190,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
private fun complete(success: Result) {
|
||||
Log.d(TAG, "About to complete BackupWorker with result: $success")
|
||||
isComplete = true
|
||||
if (engine != null) {
|
||||
MainActivity.cancelPlugins(engine!!)
|
||||
}
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
flutterApi = null
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package app.alextran.immich.core
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
|
||||
abstract class ImmichPlugin : FlutterPlugin {
|
||||
private var detached: Boolean = false;
|
||||
|
||||
@CallSuper
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
detached = false;
|
||||
}
|
||||
|
||||
fun detachFromEngine() {
|
||||
detached = true
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
detachFromEngine()
|
||||
}
|
||||
|
||||
fun <T> completeWhenActive(callback: (T) -> Unit, value: T) {
|
||||
if (detached) {
|
||||
return;
|
||||
}
|
||||
callback(value);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import androidx.core.database.getStringOrNull
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -27,7 +28,7 @@ sealed class AssetResult {
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) {
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
@@ -100,9 +101,15 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
|
||||
while (c.moveToNext()) {
|
||||
val id = c.getLong(idColumn).toString()
|
||||
val name = c.getStringOrNull(nameColumn)
|
||||
val bucketId = c.getStringOrNull(bucketIdColumn)
|
||||
val path = c.getStringOrNull(dataColumn)
|
||||
|
||||
val path = c.getString(dataColumn)
|
||||
if (path.isNullOrBlank() || !File(path).exists()) {
|
||||
// Skip assets with invalid metadata
|
||||
if (
|
||||
name.isNullOrBlank() || bucketId.isNullOrBlank() ||
|
||||
path.isNullOrBlank() || !File(path).exists()
|
||||
) {
|
||||
yield(AssetResult.InvalidAsset(id))
|
||||
continue
|
||||
}
|
||||
@@ -112,7 +119,6 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
|
||||
else -> 0
|
||||
}
|
||||
val name = c.getString(nameColumn)
|
||||
// Date taken is milliseconds since epoch, Date added is seconds since epoch
|
||||
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
|
||||
?: c.getLong(dateAddedColumn)
|
||||
@@ -123,7 +129,6 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
// Duration is milliseconds
|
||||
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
|
||||
else c.getLong(durationColumn) / 1000
|
||||
val bucketId = c.getString(bucketIdColumn)
|
||||
val orientation = c.getInt(orientationColumn)
|
||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||
|
||||
@@ -237,7 +242,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
callback: (Result<List<HashResult>>) -> Unit
|
||||
) {
|
||||
if (assetIds.isEmpty()) {
|
||||
callback(Result.success(emptyList()))
|
||||
completeWhenActive(callback, Result.success(emptyList()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -253,10 +258,10 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
callback(Result.success(results))
|
||||
completeWhenActive(callback, Result.success(results))
|
||||
} catch (e: CancellationException) {
|
||||
callback(
|
||||
Result.failure(
|
||||
completeWhenActive(
|
||||
callback, Result.failure(
|
||||
FlutterError(
|
||||
HASHING_CANCELLED_CODE,
|
||||
"Hashing operation was cancelled",
|
||||
@@ -265,7 +270,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
callback(Result.failure(e))
|
||||
completeWhenActive(callback, Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
mobile/ios/.gitignore
vendored
1
mobile/ios/.gitignore
vendored
@@ -33,3 +33,4 @@ Runner/GeneratedPluginRegistrant.*
|
||||
!default.perspectivev3
|
||||
|
||||
fastlane/report.xml
|
||||
Gemfile.lock
|
||||
@@ -1,3 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "cocoapods"
|
||||
@@ -1,218 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.932.0)
|
||||
aws-sdk-core (3.196.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.81.0)
|
||||
aws-sdk-core (~> 3, >= 3.193.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.151.0)
|
||||
aws-sdk-core (~> 3, >= 3.194.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.110.0)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.1)
|
||||
fastlane (2.214.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (~> 0.1.1)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (>= 1.4.5, < 2.0.0)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.7.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.4.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.2)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.1.1)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
public_suffix (4.0.7)
|
||||
rake (13.2.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.19.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
strscan (3.1.0)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (1.8.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.25.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (>= 3.3.2, < 4.0)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
x86_64-darwin-21
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.7
|
||||
@@ -16,6 +16,65 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "iOS Release to TestFlight"
|
||||
lane :release_ci do
|
||||
# Setup CI environment
|
||||
setup_ci
|
||||
|
||||
# Load App Store Connect API Key
|
||||
api_key = app_store_connect_api_key(
|
||||
key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
|
||||
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
|
||||
key_filepath: "api_key.json"
|
||||
)
|
||||
|
||||
# Import certificate and provisioning profile
|
||||
import_certificate(
|
||||
certificate_path: "certificate.p12",
|
||||
certificate_password: ENV["IOS_CERTIFICATE_PASSWORD"],
|
||||
keychain_name: ENV["KEYCHAIN_NAME"],
|
||||
keychain_password: ENV["KEYCHAIN_PASSWORD"]
|
||||
)
|
||||
|
||||
# Install provisioning profile
|
||||
install_provisioning_profile(path: "profile.mobileprovision")
|
||||
|
||||
# Configure code signing
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"],
|
||||
profile_name: "app.alextran.immich AppStore"
|
||||
)
|
||||
|
||||
# Increment build number
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
app_identifier: "app.alextran.immich"
|
||||
) + 1,
|
||||
xcodeproj: "./Runner.xcodeproj"
|
||||
)
|
||||
|
||||
# Build the app
|
||||
build_app(
|
||||
scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
export_method: "app-store",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
"app.alextran.immich" => "app.alextran.immich AppStore"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Upload to TestFlight
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "iOS Release"
|
||||
lane :release do
|
||||
enable_automatic_code_signing(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
enum SortOrder { asc, desc }
|
||||
|
||||
enum TextSearchType { context, filename, description }
|
||||
enum TextSearchType { context, filename, description, ocr }
|
||||
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
|
||||
@@ -114,10 +114,10 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
configureFileDownloaderNotifications();
|
||||
|
||||
// Notify the host that the background worker service has been initialized and is ready to use
|
||||
_backgroundHostApi.onInitialized();
|
||||
unawaited(_backgroundHostApi.onInitialized());
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Failed to initialize background worker", error, stack);
|
||||
_backgroundHostApi.close();
|
||||
unawaited(_backgroundHostApi.close());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ class LocalSyncService {
|
||||
|
||||
if (assetsToUpsert.isEmpty && assetsToDelete.isEmpty) {
|
||||
_log.fine("No asset changes detected in album ${deviceAlbum.name}. Updating metadata.");
|
||||
_localAlbumRepository.upsert(updatedDeviceAlbum);
|
||||
await _localAlbumRepository.upsert(updatedDeviceAlbum);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,10 @@ class RemoteAlbumService {
|
||||
return _repository.getCount();
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> getAlbumsContainingAsset(String assetId) {
|
||||
return _repository.getAlbumsContainingAsset(assetId);
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
|
||||
// map album IDs to their newest asset dates
|
||||
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
|
||||
|
||||
@@ -33,6 +33,7 @@ enum TimelineOrigin {
|
||||
map,
|
||||
search,
|
||||
deepLink,
|
||||
albumActivities,
|
||||
}
|
||||
|
||||
class TimelineFactory {
|
||||
|
||||
@@ -68,7 +68,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
final cacheManager = this.cacheManager;
|
||||
final streamController = StreamController<List<int>>(sync: true);
|
||||
final Stream<List<int>> stream;
|
||||
cacheManager?.putStreamedFile(url, streamController.stream);
|
||||
unawaited(cacheManager?.putStreamedFile(url, streamController.stream));
|
||||
stream = response.map((chunk) {
|
||||
if (_isCancelled) {
|
||||
throw StateError('Cancelled request');
|
||||
@@ -81,11 +81,11 @@ class RemoteImageRequest extends ImageRequest {
|
||||
|
||||
try {
|
||||
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
|
||||
streamController.close();
|
||||
unawaited(streamController.close());
|
||||
return await ImmutableBuffer.fromUint8List(bytes);
|
||||
} catch (e) {
|
||||
streamController.addError(e);
|
||||
streamController.close();
|
||||
unawaited(streamController.close());
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return await _decodeBuffer(buffer, decode, scale);
|
||||
} catch (e) {
|
||||
log.severe('Failed to decode cached image', e);
|
||||
_evictFile(url);
|
||||
unawaited(_evictFile(url));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,15 +361,13 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
return _db.managers.localAlbumEntity.count();
|
||||
}
|
||||
|
||||
Future unlinkRemoteAlbum(String id) async {
|
||||
return _db.localAlbumEntity.update()
|
||||
..where((row) => row.id.equals(id))
|
||||
..write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)));
|
||||
Future<void> unlinkRemoteAlbum(String id) async {
|
||||
final query = _db.localAlbumEntity.update()..where((row) => row.id.equals(id));
|
||||
await query.write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)));
|
||||
}
|
||||
|
||||
Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
|
||||
return _db.localAlbumEntity.update()
|
||||
..where((row) => row.id.equals(localAlbumId))
|
||||
..write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId)));
|
||||
Future<void> linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
|
||||
final query = _db.localAlbumEntity.update()..where((row) => row.id.equals(localAlbumId));
|
||||
await query.write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +382,61 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get();
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> getAlbumsContainingAsset(String assetId) async {
|
||||
// Note: this needs to be 2 queries as the where clause filtering causes the assetCount to always be 1
|
||||
final albumIdsQuery = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAlbumAssetEntity.albumId])
|
||||
..where(_db.remoteAlbumAssetEntity.assetId.equals(assetId));
|
||||
|
||||
final albumIds = await albumIdsQuery.map((row) => row.read(_db.remoteAlbumAssetEntity.albumId)!).get();
|
||||
|
||||
if (albumIds.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
|
||||
final query =
|
||||
_db.remoteAlbumEntity.select().join([
|
||||
leftOuterJoin(
|
||||
_db.remoteAlbumAssetEntity,
|
||||
_db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.userEntity,
|
||||
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAlbumUserEntity,
|
||||
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(_db.remoteAlbumEntity.id.isIn(albumIds) & _db.remoteAssetEntity.deletedAt.isNull())
|
||||
..addColumns([assetCount])
|
||||
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
|
||||
..addColumns([_db.userEntity.name])
|
||||
..groupBy([_db.remoteAlbumEntity.id]);
|
||||
|
||||
return query
|
||||
.map(
|
||||
(row) => row
|
||||
.readTable(_db.remoteAlbumEntity)
|
||||
.toDto(
|
||||
ownerName: row.read(_db.userEntity.name) ?? '',
|
||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
|
||||
assetCount: row.read(assetCount) ?? 0,
|
||||
),
|
||||
)
|
||||
.get();
|
||||
}
|
||||
}
|
||||
|
||||
extension on RemoteAlbumEntityData {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:openapi/api.dart';
|
||||
|
||||
class SearchApiRepository extends ApiRepository {
|
||||
final SearchApi _api;
|
||||
|
||||
const SearchApiRepository(this._api);
|
||||
|
||||
Future<SearchResponseDto?> search(SearchFilter filter, int page) {
|
||||
@@ -15,10 +16,12 @@ class SearchApiRepository extends ApiRepository {
|
||||
type = AssetTypeEnum.VIDEO;
|
||||
}
|
||||
|
||||
if (filter.context != null && filter.context!.isNotEmpty) {
|
||||
if ((filter.context != null && filter.context!.isNotEmpty) ||
|
||||
(filter.assetId != null && filter.assetId!.isNotEmpty)) {
|
||||
return _api.searchSmart(
|
||||
SmartSearchDto(
|
||||
query: filter.context!,
|
||||
query: filter.context,
|
||||
queryAssetId: filter.assetId,
|
||||
language: filter.language,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
@@ -43,6 +46,7 @@ class SearchApiRepository extends ApiRepository {
|
||||
originalFileName: filter.filename != null && filter.filename!.isNotEmpty ? filter.filename : null,
|
||||
country: filter.location.country,
|
||||
description: filter.description != null && filter.description!.isNotEmpty ? filter.description : null,
|
||||
ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? filter.ocr : null,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@@ -89,5 +90,17 @@ class StorageRepository {
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error clearing cache", error, stackTrace);
|
||||
}
|
||||
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await Directory.systemTemp.exists()) {
|
||||
await Directory.systemTemp.delete(recursive: true);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error deleting temporary directory", error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Draw the app from edge to edge
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
|
||||
// Sets the navigation bar color
|
||||
SystemUiOverlayStyle overlayStyle = const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent);
|
||||
|
||||
@@ -176,7 +176,9 @@ class SearchFilter {
|
||||
String? context;
|
||||
String? filename;
|
||||
String? description;
|
||||
String? ocr;
|
||||
String? language;
|
||||
String? assetId;
|
||||
Set<PersonDto> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
@@ -190,7 +192,9 @@ class SearchFilter {
|
||||
this.context,
|
||||
this.filename,
|
||||
this.description,
|
||||
this.ocr,
|
||||
this.language,
|
||||
this.assetId,
|
||||
required this.people,
|
||||
required this.location,
|
||||
required this.camera,
|
||||
@@ -203,6 +207,8 @@ class SearchFilter {
|
||||
return (context == null || (context != null && context!.isEmpty)) &&
|
||||
(filename == null || (filename!.isEmpty)) &&
|
||||
(description == null || (description!.isEmpty)) &&
|
||||
(assetId == null || (assetId!.isEmpty)) &&
|
||||
(ocr == null || (ocr!.isEmpty)) &&
|
||||
people.isEmpty &&
|
||||
location.country == null &&
|
||||
location.state == null &&
|
||||
@@ -222,6 +228,8 @@ class SearchFilter {
|
||||
String? filename,
|
||||
String? description,
|
||||
String? language,
|
||||
String? ocr,
|
||||
String? assetId,
|
||||
Set<PersonDto>? people,
|
||||
SearchLocationFilter? location,
|
||||
SearchCameraFilter? camera,
|
||||
@@ -234,6 +242,8 @@ class SearchFilter {
|
||||
filename: filename ?? this.filename,
|
||||
description: description ?? this.description,
|
||||
language: language ?? this.language,
|
||||
ocr: ocr ?? this.ocr,
|
||||
assetId: assetId ?? this.assetId,
|
||||
people: people ?? this.people,
|
||||
location: location ?? this.location,
|
||||
camera: camera ?? this.camera,
|
||||
@@ -245,7 +255,7 @@ class SearchFilter {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -256,6 +266,8 @@ class SearchFilter {
|
||||
other.filename == filename &&
|
||||
other.description == description &&
|
||||
other.language == language &&
|
||||
other.ocr == ocr &&
|
||||
other.assetId == assetId &&
|
||||
other.people == people &&
|
||||
other.location == location &&
|
||||
other.camera == camera &&
|
||||
@@ -270,6 +282,8 @@ class SearchFilter {
|
||||
filename.hashCode ^
|
||||
description.hashCode ^
|
||||
language.hashCode ^
|
||||
ocr.hashCode ^
|
||||
assetId.hashCode ^
|
||||
people.hashCode ^
|
||||
location.hashCode ^
|
||||
camera.hashCode ^
|
||||
|
||||
@@ -5,33 +5,37 @@ class ServerFeatures {
|
||||
final bool map;
|
||||
final bool oauthEnabled;
|
||||
final bool passwordLogin;
|
||||
final bool ocr;
|
||||
|
||||
const ServerFeatures({
|
||||
required this.trash,
|
||||
required this.map,
|
||||
required this.oauthEnabled,
|
||||
required this.passwordLogin,
|
||||
this.ocr = false,
|
||||
});
|
||||
|
||||
ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin}) {
|
||||
ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) {
|
||||
return ServerFeatures(
|
||||
trash: trash ?? this.trash,
|
||||
map: map ?? this.map,
|
||||
oauthEnabled: oauthEnabled ?? this.oauthEnabled,
|
||||
passwordLogin: passwordLogin ?? this.passwordLogin,
|
||||
ocr: ocr ?? this.ocr,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin)';
|
||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)';
|
||||
}
|
||||
|
||||
ServerFeatures.fromDto(ServerFeaturesDto dto)
|
||||
: trash = dto.trash,
|
||||
map = dto.map,
|
||||
oauthEnabled = dto.oauth,
|
||||
passwordLogin = dto.passwordLogin;
|
||||
passwordLogin = dto.passwordLogin,
|
||||
ocr = dto.ocr;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant ServerFeatures other) {
|
||||
@@ -40,11 +44,12 @@ class ServerFeatures {
|
||||
return other.trash == trash &&
|
||||
other.map == map &&
|
||||
other.oauthEnabled == oauthEnabled &&
|
||||
other.passwordLogin == passwordLogin;
|
||||
other.passwordLogin == passwordLogin &&
|
||||
other.ocr == ocr;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode;
|
||||
return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -51,7 +53,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||
final isSuccess = await ref.read(albumProvider.notifier).leaveAlbum(album);
|
||||
|
||||
if (isSuccess) {
|
||||
context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()]));
|
||||
unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])));
|
||||
} else {
|
||||
showErrorMessage();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -29,8 +31,8 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget {
|
||||
|
||||
if (newAlbum != null) {
|
||||
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
context.maybePop(true);
|
||||
context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()]));
|
||||
unawaited(context.maybePop(true));
|
||||
unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])));
|
||||
}
|
||||
|
||||
ScaffoldMessenger(
|
||||
@@ -109,8 +111,8 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget {
|
||||
centerTitle: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () async {
|
||||
context.maybePop();
|
||||
onPressed: () {
|
||||
unawaited(context.maybePop());
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
|
||||
@@ -155,7 +155,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
// waited until returning from selection
|
||||
await ref.read(backupProvider.notifier).backupAlbumSelectionDone();
|
||||
// waited until backup albums are stored in DB
|
||||
ref.read(albumProvider.notifier).refreshDeviceAlbums();
|
||||
await ref.read(albumProvider.notifier).refreshDeviceAlbums();
|
||||
},
|
||||
child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
),
|
||||
|
||||
@@ -270,7 +270,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
|
||||
if (currentUser == null) {
|
||||
return;
|
||||
}
|
||||
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
unawaited(ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id));
|
||||
},
|
||||
child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -31,55 +32,66 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final asset = candidates[index];
|
||||
final albumsAsyncValue = ref.watch(driftCandidateBackupAlbumInfoProvider(asset.id));
|
||||
return LargeLeadingTile(
|
||||
title: Text(
|
||||
asset.name,
|
||||
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500, fontSize: 16),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
asset.createdAt.toString(),
|
||||
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
|
||||
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
|
||||
return FutureBuilder<String?>(
|
||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||
builder: (context, snapshot) {
|
||||
final displayName = snapshot.data ?? asset.name;
|
||||
return LargeLeadingTile(
|
||||
title: Text(
|
||||
displayName,
|
||||
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500, fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
asset.checksum ?? "N/A",
|
||||
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
albumsAsyncValue.when(
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Text(
|
||||
albums.map((a) => a.name).join(', '),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
asset.createdAt.toString(),
|
||||
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
Text(
|
||||
asset.checksum ?? "N/A",
|
||||
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Text(
|
||||
'error_saving_image'.tr(args: [error.toString()]),
|
||||
style: TextStyle(color: context.colorScheme.error),
|
||||
),
|
||||
loading: () => const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
albumsAsyncValue.when(
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Text(
|
||||
albums.map((a) => a.name).join(', '),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Text(
|
||||
'error_saving_image'.tr(args: [error.toString()]),
|
||||
style: TextStyle(color: context.colorScheme.error),
|
||||
),
|
||||
loading: () =>
|
||||
const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
trailing: const Padding(padding: EdgeInsets.only(right: 24, left: 8), child: Icon(Icons.image_search)),
|
||||
onTap: () async {
|
||||
await context.maybePop();
|
||||
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
trailing: const Padding(
|
||||
padding: EdgeInsets.only(right: 24, left: 8),
|
||||
child: Icon(Icons.image_search),
|
||||
),
|
||||
onTap: () async {
|
||||
await context.maybePop();
|
||||
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -170,8 +170,8 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showFileDetailDialog(BuildContext context, DriftUploadStatus item) async {
|
||||
showDialog(
|
||||
Future<void> _showFileDetailDialog(BuildContext context, DriftUploadStatus item) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => FileDetailDialog(uploadStatus: item),
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ class ActivitiesPage extends HookConsumerWidget {
|
||||
Future<void> onAddComment(String comment) async {
|
||||
await activityNotifier.addComment(comment);
|
||||
// Scroll to the end of the list to show the newly added activity
|
||||
listViewScrollController.animateTo(
|
||||
await listViewScrollController.animateTo(
|
||||
listViewScrollController.position.maxScrollExtent + 200,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -170,11 +172,11 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
.createAlbum(ref.read(albumTitleProvider), selectedAssets.value);
|
||||
|
||||
if (newAlbum != null) {
|
||||
ref.read(albumProvider.notifier).refreshRemoteAlbums();
|
||||
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
|
||||
selectedAssets.value = {};
|
||||
ref.read(albumTitleProvider.notifier).clearAlbumTitle();
|
||||
ref.read(albumViewerProvider.notifier).disableEditAlbum();
|
||||
context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id));
|
||||
unawaited(context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
} catch (e) {
|
||||
// swallow error silently
|
||||
log.severe('Error precaching next image: $e');
|
||||
context.maybePop();
|
||||
await context.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
static final log = Logger('NativeVideoViewer');
|
||||
final Asset asset;
|
||||
final bool showControls;
|
||||
final int playbackDelayFactor;
|
||||
@@ -59,8 +60,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||
final isVisible = useState(Platform.isIOS && asset.isLocal);
|
||||
|
||||
final log = Logger('NativeVideoViewerPage');
|
||||
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
final isVideoReady = useState(false);
|
||||
@@ -142,7 +141,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
interval: const Duration(milliseconds: 100),
|
||||
maxWaitTime: const Duration(milliseconds: 200),
|
||||
);
|
||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
|
||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
@@ -153,28 +152,14 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldSeek = (oldControls?.position ?? 0) ~/ 1;
|
||||
final newSeek = newControls.position ~/ 1;
|
||||
final oldSeek = oldControls?.position.inMilliseconds;
|
||||
final newSeek = newControls.position.inMilliseconds;
|
||||
if (oldSeek != newSeek || newControls.restarted) {
|
||||
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
||||
}
|
||||
|
||||
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
||||
// Make sure the last seek is complete before pausing or playing
|
||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||
if (seekDebouncer.isActive) {
|
||||
await seekDebouncer.drain();
|
||||
}
|
||||
|
||||
try {
|
||||
if (newControls.pause) {
|
||||
await playerController.pause();
|
||||
} else {
|
||||
await playerController.play();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
}
|
||||
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -234,7 +219,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration(seconds: playbackInfo.position);
|
||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
|
||||
|
||||
// Check if the video is buffering
|
||||
if (playbackInfo.status == PlaybackStatus.playing) {
|
||||
@@ -282,11 +267,13 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
log.severe('Error loading video source: $error');
|
||||
});
|
||||
unawaited(
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
log.severe('Error loading video source: $error');
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
nc.setLoop(loopVideo);
|
||||
unawaited(nc.setLoop(loopVideo));
|
||||
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
@@ -357,12 +344,12 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
|
||||
useOnAppLifecycleStateChange((_, state) async {
|
||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||
controller.value?.play();
|
||||
await controller.value?.play();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
final videoPlaying = await controller.value?.isPlaying();
|
||||
if (videoPlaying ?? true) {
|
||||
shouldPlayOnForeground.value = true;
|
||||
controller.value?.pause();
|
||||
await controller.value?.pause();
|
||||
} else {
|
||||
shouldPlayOnForeground.value = false;
|
||||
}
|
||||
@@ -391,4 +378,35 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPauseChange(
|
||||
BuildContext context,
|
||||
NativeVideoPlayerController controller,
|
||||
Debouncer seekDebouncer,
|
||||
bool isPaused,
|
||||
) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the last seek is complete before pausing or playing
|
||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||
if (seekDebouncer.isActive) {
|
||||
await seekDebouncer.drain();
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPaused) {
|
||||
await controller.pause();
|
||||
} else {
|
||||
await controller.play();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,48 +55,50 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||
|
||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||
(_) async {
|
||||
try {
|
||||
wsProvider.connect();
|
||||
infoProvider.getServerInfo();
|
||||
unawaited(
|
||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||
(_) async {
|
||||
try {
|
||||
wsProvider.connect();
|
||||
unawaited(infoProvider.getServerInfo());
|
||||
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
bool syncSuccess = false;
|
||||
await Future.wait([
|
||||
backgroundManager.syncLocal(),
|
||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||
]);
|
||||
|
||||
if (syncSuccess) {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
bool syncSuccess = false;
|
||||
await Future.wait([
|
||||
backgroundManager.hashAssets().then((_) {
|
||||
_resumeBackup(backupProvider);
|
||||
}),
|
||||
_resumeBackup(backupProvider),
|
||||
backgroundManager.syncLocal(),
|
||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||
]);
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
}
|
||||
|
||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
backgroundManager.hashAssets().then((_) {
|
||||
_resumeBackup(backupProvider);
|
||||
}),
|
||||
_resumeBackup(backupProvider),
|
||||
]);
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
}
|
||||
|
||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.severe('Failed establishing connection to the server: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
log.severe('Failed establishing connection to the server: $e');
|
||||
}
|
||||
},
|
||||
onError: (exception) => {
|
||||
log.severe('Failed to update auth info with access token: $accessToken'),
|
||||
ref.read(authProvider.notifier).logout(),
|
||||
context.replaceRoute(const LoginRoute()),
|
||||
},
|
||||
},
|
||||
onError: (exception) => {
|
||||
log.severe('Failed to update auth info with access token: $accessToken'),
|
||||
ref.read(authProvider.notifier).logout(),
|
||||
context.replaceRoute(const LoginRoute()),
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
log.severe('Missing crucial offline login info - Logging out completely');
|
||||
ref.read(authProvider.notifier).logout();
|
||||
context.replaceRoute(const LoginRoute());
|
||||
unawaited(ref.read(authProvider.notifier).logout());
|
||||
unawaited(context.replaceRoute(const LoginRoute()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,11 +108,11 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final needBetaMigration = Store.get(StoreKey.needBetaMigration, false);
|
||||
if (needBetaMigration) {
|
||||
await Store.put(StoreKey.needBetaMigration, false);
|
||||
context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]);
|
||||
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
|
||||
return;
|
||||
}
|
||||
|
||||
context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute());
|
||||
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));
|
||||
}
|
||||
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
@@ -120,7 +122,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final hasPermission = await ref.read(galleryPermissionNotifier.notifier).hasPermission;
|
||||
if (hasPermission) {
|
||||
// Resume backup (if enable) then navigate
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
await ref.watch(backupProvider.notifier).resumeBackup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +132,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
if (currentUser != null) {
|
||||
notifier.handleBackupResume(currentUser.id);
|
||||
unawaited(notifier.handleBackupResume(currentUser.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
@@ -77,7 +78,7 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
||||
}
|
||||
|
||||
return AutoTabsRouter(
|
||||
routes: [const MainTimelineRoute(), DriftSearchRoute(), const DriftAlbumsRoute(), const DriftLibraryRoute()],
|
||||
routes: const [MainTimelineRoute(), DriftSearchRoute(), DriftAlbumsRoute(), DriftLibraryRoute()],
|
||||
duration: const Duration(milliseconds: 600),
|
||||
transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child),
|
||||
builder: (context, child) {
|
||||
@@ -114,6 +115,10 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
|
||||
ref.invalidate(driftMemoryFutureProvider);
|
||||
}
|
||||
|
||||
if (router.activeIndex != 1 && index == 1) {
|
||||
ref.read(searchPreFilterProvider.notifier).clear();
|
||||
}
|
||||
|
||||
// On Search page tapped
|
||||
if (router.activeIndex == 1 && index == 1) {
|
||||
ref.read(searchInputFocusProvider).requestFocus();
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:crop_image/crop_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
||||
import 'edit.page.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
|
||||
/// A widget for cropping an image.
|
||||
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
||||
@@ -35,7 +38,7 @@ class CropImagePage extends HookWidget {
|
||||
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
||||
onPressed: () async {
|
||||
final croppedImage = await cropController.croppedImage();
|
||||
context.pushRoute(EditImageRoute(asset: asset, image: croppedImage, isEdited: true));
|
||||
unawaited(context.pushRoute(EditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/constants/filters.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
/// A widget for filtering an image.
|
||||
@@ -74,7 +75,7 @@ class FilterImagePage extends HookWidget {
|
||||
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
||||
onPressed: () async {
|
||||
final filteredImage = await applyFilterAndConvert(colorFilter.value);
|
||||
context.pushRoute(EditImageRoute(asset: asset, image: filteredImage, isEdited: true));
|
||||
unawaited(context.pushRoute(EditImageRoute(asset: asset, image: filteredImage, isEdited: true)));
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' show useState;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/local_auth.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
@RoutePage()
|
||||
class PinAuthPage extends HookConsumerWidget {
|
||||
@@ -35,9 +37,9 @@ class PinAuthPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
if (isBetaTimeline) {
|
||||
context.replaceRoute(const DriftLockedFolderRoute());
|
||||
unawaited(context.replaceRoute(const DriftLockedFolderRoute()));
|
||||
} else {
|
||||
context.replaceRoute(const LockedRoute());
|
||||
unawaited(context.replaceRoute(const LockedRoute()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
changeExpiry: changeExpiry,
|
||||
);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
context.maybePop();
|
||||
await context.maybePop();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -82,10 +82,12 @@ class PhotosPage extends HookConsumerWidget {
|
||||
final fullRefresh = refreshCount.value > 0;
|
||||
|
||||
if (fullRefresh) {
|
||||
Future.wait([
|
||||
ref.read(assetProvider.notifier).getAllAsset(clear: true),
|
||||
ref.read(albumProvider.notifier).refreshRemoteAlbums(),
|
||||
]);
|
||||
unawaited(
|
||||
Future.wait([
|
||||
ref.read(assetProvider.notifier).getAllAsset(clear: true),
|
||||
ref.read(albumProvider.notifier).refreshRemoteAlbums(),
|
||||
]),
|
||||
);
|
||||
|
||||
// refresh was forced: user requested another refresh within 2 seconds
|
||||
refreshCount.value = 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
@@ -83,7 +84,7 @@ class MapPage extends HookConsumerWidget {
|
||||
isLoading.value = true;
|
||||
markers.value = await ref.read(mapMarkersProvider.future);
|
||||
assetsDebouncer.run(updateAssetsInBounds);
|
||||
reloadLayers();
|
||||
await reloadLayers();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -128,7 +129,7 @@ class MapPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
if (marker != null) {
|
||||
updateAssetMarkerPosition(marker);
|
||||
await updateAssetMarkerPosition(marker);
|
||||
} else {
|
||||
// If no asset was previously selected and no new asset is available, close the bottom sheet
|
||||
if (selectedMarker.value == null) {
|
||||
@@ -165,7 +166,7 @@ class MapPage extends HookConsumerWidget {
|
||||
if (asset.isVideo) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
context.pushRoute(GalleryViewerRoute(initialIndex: 0, heroOffset: 0, renderList: renderList));
|
||||
unawaited(context.pushRoute(GalleryViewerRoute(initialIndex: 0, heroOffset: 0, renderList: renderList)));
|
||||
}
|
||||
|
||||
/// BOTTOM SHEET CALLBACKS
|
||||
@@ -209,7 +210,7 @@ class MapPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (mapController.value != null && location != null) {
|
||||
mapController.value!.animateCamera(
|
||||
await mapController.value!.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), mapZoomToAssetLevel),
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
|
||||
@@ -8,9 +8,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/utils/map_utils.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:immich_mobile/utils/map_utils.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MapLocationPickerPage extends HookConsumerWidget {
|
||||
@@ -30,7 +30,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
|
||||
Future<void> onMapClick(Point<num> point, LatLng centre) async {
|
||||
selectedLatLng.value = centre;
|
||||
controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
||||
if (marker.value != null) {
|
||||
await controller.value?.updateSymbol(marker.value!, SymbolOptions(geometry: centre));
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
|
||||
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
|
||||
selectedLatLng.value = currentLatLng;
|
||||
controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
|
||||
}
|
||||
|
||||
return MapThemeOverride(
|
||||
|
||||
@@ -266,7 +266,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
filter.value = filter.value.copyWith(date: SearchDateFilter());
|
||||
|
||||
dateRangeCurrentFilterWidget.value = null;
|
||||
search();
|
||||
unawaited(search());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
search();
|
||||
unawaited(search());
|
||||
}
|
||||
|
||||
// MEDIA PICKER
|
||||
@@ -389,15 +389,18 @@ class SearchPage extends HookConsumerWidget {
|
||||
handleTextSubmitted(String value) {
|
||||
switch (textSearchType.value) {
|
||||
case TextSearchType.context:
|
||||
filter.value = filter.value.copyWith(filename: '', context: value, description: '');
|
||||
filter.value = filter.value.copyWith(filename: '', context: value, description: '', ocr: '');
|
||||
|
||||
break;
|
||||
case TextSearchType.filename:
|
||||
filter.value = filter.value.copyWith(filename: value, context: '', description: '');
|
||||
filter.value = filter.value.copyWith(filename: value, context: '', description: '', ocr: '');
|
||||
|
||||
break;
|
||||
case TextSearchType.description:
|
||||
filter.value = filter.value.copyWith(filename: '', context: '', description: value);
|
||||
filter.value = filter.value.copyWith(filename: '', context: '', description: value, ocr: '');
|
||||
break;
|
||||
case TextSearchType.ocr:
|
||||
filter.value = filter.value.copyWith(filename: '', context: '', description: '', ocr: value);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -408,6 +411,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
TextSearchType.context => Icons.image_search_rounded,
|
||||
TextSearchType.filename => Icons.abc_rounded,
|
||||
TextSearchType.description => Icons.text_snippet_outlined,
|
||||
TextSearchType.ocr => Icons.document_scanner_outlined,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
@@ -493,6 +497,24 @@ class SearchPage extends HookConsumerWidget {
|
||||
searchHintText.value = 'search_by_description_example'.tr();
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.document_scanner_outlined),
|
||||
title: Text(
|
||||
'search_filter_ocr'.tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.ocr,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.ocr;
|
||||
searchHintText.value = 'search_by_ocr_example'.tr();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -18,11 +19,12 @@ import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftActivitiesPage extends HookConsumerWidget {
|
||||
const DriftActivitiesPage({super.key});
|
||||
final RemoteAlbum album;
|
||||
|
||||
const DriftActivitiesPage({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
@@ -43,62 +45,65 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: asset == null ? Text(album.name) : null,
|
||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
body: activities.widgetWhen(
|
||||
onData: (data) {
|
||||
final liked = data.firstWhereOrNull(
|
||||
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
|
||||
);
|
||||
return ProviderScope(
|
||||
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: asset == null ? Text(album.name) : null,
|
||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
body: activities.widgetWhen(
|
||||
onData: (data) {
|
||||
final liked = data.firstWhereOrNull(
|
||||
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: listViewScrollController,
|
||||
itemCount: data.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.length) {
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
final activity = data[index];
|
||||
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: DismissibleActivity(
|
||||
activity.id,
|
||||
ActivityTile(activity),
|
||||
onDismiss: canDelete
|
||||
? (activityId) async => await activityNotifier.removeActivity(activity.id)
|
||||
: null,
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: listViewScrollController,
|
||||
itemCount: data.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.length) {
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
final activity = data[index];
|
||||
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: DismissibleActivity(
|
||||
activity.id,
|
||||
ActivityTile(activity),
|
||||
onDismiss: canDelete
|
||||
? (activityId) async => await activityNotifier.removeActivity(activity.id)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
|
||||
),
|
||||
child: DriftActivityTextField(
|
||||
isEnabled: album.isActivityEnabled,
|
||||
likeId: liked?.id,
|
||||
onSubmit: onAddComment,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
|
||||
),
|
||||
child: DriftActivityTextField(
|
||||
isEnabled: album.isActivityEnabled,
|
||||
likeId: liked?.id,
|
||||
onSubmit: onAddComment,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
|
||||
@@ -43,7 +42,6 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
|
||||
),
|
||||
AlbumSelector(
|
||||
onAlbumSelected: (album) {
|
||||
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
|
||||
context.router.push(RemoteAlbumRoute(album: album));
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
@@ -20,15 +23,11 @@ import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||
const DriftAlbumOptionsPage({super.key});
|
||||
final RemoteAlbum album;
|
||||
const DriftAlbumOptionsPage({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider);
|
||||
if (album == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id));
|
||||
final userId = ref.watch(authProvider).userId;
|
||||
final activityEnabled = useState(album.isActivityEnabled);
|
||||
@@ -47,7 +46,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||
void leaveAlbum() async {
|
||||
try {
|
||||
await ref.read(remoteAlbumProvider.notifier).leaveAlbum(album.id, userId: userId);
|
||||
context.navigateTo(const DriftAlbumsRoute());
|
||||
unawaited(context.navigateTo(const DriftAlbumsRoute()));
|
||||
} catch (_) {
|
||||
showErrorMessage();
|
||||
}
|
||||
@@ -189,48 +188,51 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () => context.maybePop(null),
|
||||
return ProviderScope(
|
||||
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () => context.maybePop(null),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Text("options".t(context: context)),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Text("options".t(context: context)),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
if (isOwner)
|
||||
SwitchListTile.adaptive(
|
||||
value: activityEnabled.value,
|
||||
onChanged: (bool value) async {
|
||||
activityEnabled.value = value;
|
||||
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
|
||||
},
|
||||
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"comments_and_likes",
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
).t(context: context),
|
||||
subtitle: Text(
|
||||
"let_others_respond",
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
).t(context: context),
|
||||
),
|
||||
buildSectionTitle("shared_album_section_people_title".t(context: context)),
|
||||
if (isOwner) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_rounded),
|
||||
title: Text("invite_people".t(context: context)),
|
||||
onTap: () async => addUsers(),
|
||||
),
|
||||
const Divider(indent: 16),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
if (isOwner)
|
||||
SwitchListTile.adaptive(
|
||||
value: activityEnabled.value,
|
||||
onChanged: (bool value) async {
|
||||
activityEnabled.value = value;
|
||||
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
|
||||
},
|
||||
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"comments_and_likes",
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
).t(context: context),
|
||||
subtitle: Text(
|
||||
"let_others_respond",
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
).t(context: context),
|
||||
),
|
||||
buildSectionTitle("shared_album_section_people_title".t(context: context)),
|
||||
if (isOwner) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_rounded),
|
||||
title: Text("invite_people".t(context: context)),
|
||||
onTap: () async => addUsers(),
|
||||
),
|
||||
const Divider(indent: 16),
|
||||
],
|
||||
buildOwnerInfo(),
|
||||
buildSharedUsersList(),
|
||||
],
|
||||
buildOwnerInfo(),
|
||||
buildSharedUsersList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -6,7 +8,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
|
||||
|
||||
@@ -178,8 +179,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
);
|
||||
|
||||
if (album != null) {
|
||||
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
|
||||
context.replaceRoute(RemoteAlbumRoute(album: album));
|
||||
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -139,7 +141,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
|
||||
context.pushRoute(const DriftAlbumsRoute());
|
||||
unawaited(context.pushRoute(const DriftAlbumsRoute()));
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
@@ -161,12 +163,12 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
setState(() {
|
||||
_album = _album.copyWith(name: result.name, description: result.description ?? '');
|
||||
});
|
||||
HapticFeedback.mediumImpact();
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showActivity(BuildContext context) async {
|
||||
context.pushRoute(const DriftActivitiesRoute());
|
||||
unawaited(context.pushRoute(DriftActivitiesRoute(album: _album)));
|
||||
}
|
||||
|
||||
Future<void> showOptionSheet(BuildContext context) async {
|
||||
@@ -175,56 +177,58 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
final canAddPhotos =
|
||||
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
isScrollControlled: false,
|
||||
builder: (context) {
|
||||
return DriftRemoteAlbumOption(
|
||||
onDeleteAlbum: isOwner
|
||||
? () async {
|
||||
await deleteAlbum(context);
|
||||
if (context.mounted) {
|
||||
unawaited(
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
isScrollControlled: false,
|
||||
builder: (context) {
|
||||
return DriftRemoteAlbumOption(
|
||||
onDeleteAlbum: isOwner
|
||||
? () async {
|
||||
await deleteAlbum(context);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onAddUsers: isOwner
|
||||
? () async {
|
||||
await addUsers(context);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onAddUsers: isOwner
|
||||
? () async {
|
||||
await addUsers(context);
|
||||
context.pop();
|
||||
}
|
||||
: null,
|
||||
onAddPhotos: isOwner || canAddPhotos
|
||||
? () async {
|
||||
await addAssets(context);
|
||||
context.pop();
|
||||
}
|
||||
: null,
|
||||
onToggleAlbumOrder: isOwner
|
||||
? () async {
|
||||
await toggleAlbumOrder();
|
||||
context.pop();
|
||||
}
|
||||
: null,
|
||||
onEditAlbum: isOwner
|
||||
? () async {
|
||||
context.pop();
|
||||
await showEditTitleAndDescription(context);
|
||||
}
|
||||
: null,
|
||||
onCreateSharedLink: isOwner
|
||||
? () async {
|
||||
context.pop();
|
||||
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
|
||||
}
|
||||
: null,
|
||||
onShowOptions: () {
|
||||
context.pop();
|
||||
context.pushRoute(const DriftAlbumOptionsRoute());
|
||||
},
|
||||
);
|
||||
},
|
||||
: null,
|
||||
onAddPhotos: isOwner || canAddPhotos
|
||||
? () async {
|
||||
await addAssets(context);
|
||||
context.pop();
|
||||
}
|
||||
: null,
|
||||
onToggleAlbumOrder: isOwner
|
||||
? () async {
|
||||
await toggleAlbumOrder();
|
||||
context.pop();
|
||||
}
|
||||
: null,
|
||||
onEditAlbum: isOwner
|
||||
? () async {
|
||||
context.pop();
|
||||
await showEditTitleAndDescription(context);
|
||||
}
|
||||
: null,
|
||||
onCreateSharedLink: isOwner
|
||||
? () async {
|
||||
context.pop();
|
||||
unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id)));
|
||||
}
|
||||
: null,
|
||||
onShowOptions: () {
|
||||
context.pop();
|
||||
context.pushRoute(DriftAlbumOptionsRoute(album: _album));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,35 +237,24 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) {
|
||||
Future.microtask(() {
|
||||
if (mounted) {
|
||||
ref.read(currentRemoteAlbumProvider.notifier).dispose();
|
||||
ref.read(remoteAlbumProvider.notifier).refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: Timeline(
|
||||
appBar: RemoteAlbumSliverAppBar(
|
||||
icon: Icons.photo_album_outlined,
|
||||
onShowOptions: () => showOptionSheet(context),
|
||||
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
|
||||
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
|
||||
onActivity: () => showActivity(context),
|
||||
),
|
||||
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
currentRemoteAlbumScopedProvider.overrideWithValue(_album),
|
||||
],
|
||||
child: Timeline(
|
||||
appBar: RemoteAlbumSliverAppBar(
|
||||
icon: Icons.photo_album_outlined,
|
||||
onShowOptions: () => showOptionSheet(context),
|
||||
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
|
||||
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
|
||||
onActivity: () => showActivity(context),
|
||||
),
|
||||
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:crop_image/crop_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -34,7 +36,7 @@ class DriftCropImagePage extends HookWidget {
|
||||
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
||||
onPressed: () async {
|
||||
final croppedImage = await cropController.croppedImage();
|
||||
context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true));
|
||||
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -70,7 +70,7 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
|
||||
}
|
||||
|
||||
ref.read(backgroundSyncProvider).syncLocal(full: true);
|
||||
unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true));
|
||||
_exitEditing(context);
|
||||
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user