diff --git a/.github/mise.toml b/.github/mise.toml
new file mode 100644
index 0000000000..6930d41187
--- /dev/null
+++ b/.github/mise.toml
@@ -0,0 +1,10 @@
+[tasks.install]
+run = "pnpm install --filter github --frozen-lockfile"
+
+[tasks.format]
+env._.path = "./node_modules/.bin"
+run = "prettier --check ."
+
+[tasks."format-fix"]
+env._.path = "./node_modules/.bin"
+run = "prettier --write ."
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index 1286b45409..e4ae09bfd7 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -1,12 +1,16 @@
name: Build Mobile
on:
- workflow_dispatch:
workflow_call:
inputs:
ref:
required: false
type: string
+ environment:
+ description: 'Target environment'
+ required: true
+ default: 'development'
+ type: string
secrets:
KEY_JKS:
required: true
@@ -16,6 +20,30 @@ on:
required: true
ANDROID_STORE_PASSWORD:
required: true
+ APP_STORE_CONNECT_API_KEY_ID:
+ required: true
+ APP_STORE_CONNECT_API_KEY_ISSUER_ID:
+ required: true
+ APP_STORE_CONNECT_API_KEY:
+ required: true
+ IOS_CERTIFICATE_P12:
+ required: true
+ IOS_CERTIFICATE_PASSWORD:
+ required: true
+ IOS_PROVISIONING_PROFILE:
+ required: true
+ IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
+ required: true
+ IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
+ required: true
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE:
+ required: true
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
+ required: true
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
+ required: true
+ FASTLANE_TEAM_ID:
+ required: true
pull_request:
push:
branches: [main]
@@ -137,7 +165,7 @@ jobs:
fi
- name: Publish Android Artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
@@ -193,17 +221,22 @@ jobs:
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
- ruby-version: '3.4.7'
+ ruby-version: '3.3'
working-directory: ./mobile/ios
- - name: Install Fastlane
+ - name: Install CocoaPods dependencies
+ working-directory: ./mobile/ios
+ run: |
+ pod install
+
+ - name: Install Fastlane
+ working-directory: ./mobile/ios
run: |
- cd mobile/ios
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
- - name: Create API Key JSON
+ - name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
@@ -212,35 +245,55 @@ jobs:
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
+ - name: Import Certificate and Provisioning Profiles
env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
+ IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
+ IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
+ ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios
run: |
+ # Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
- echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
- - name: Create keychain
+ # Decode provisioning profiles based on environment
+ if [[ "$ENVIRONMENT" == "development" ]]; then
+ echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
+ echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
+ echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
+ ls -lh profile_dev*.mobileprovision
+ else
+ echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
+ echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
+ echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
+ ls -lh profile*.mobileprovision
+ fi
+
+ - name: Create keychain and import certificate
env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
+ CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
+ working-directory: ./mobile/ios
run: |
+ # Create keychain
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
+ # Import certificate
+ security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
+ security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
+
+ # Verify certificate was imported
+ security find-identity -v -p codesigning build.keychain
+
- name: Build and deploy to TestFlight
env:
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
@@ -249,8 +302,14 @@ jobs:
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 }}
+ ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios
- run: bundle exec fastlane release_ci
+ run: |
+ if [[ "$ENVIRONMENT" == "development" ]]; then
+ bundle exec fastlane gha_testflight_dev
+ else
+ bundle exec fastlane gha_release_prod
+ fi
- name: Clean up keychain
if: always()
@@ -258,7 +317,7 @@ jobs:
security delete-keychain build.keychain || true
- name: Upload IPA artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index dae8cec1fd..fc2c9f6853 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -84,7 +84,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -105,7 +105,7 @@ jobs:
- name: Generate docker image tags
id: metadata
- uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
+ uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
flavor: |
latest=false
diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml
index ba360b50dc..b3c79f81d8 100644
--- a/.github/workflows/close-duplicates.yml
+++ b/.github/workflows/close-duplicates.yml
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
- image: ghcr.io/immich-app/mdq:main@sha256:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723
+ image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 3f32478c0c..34228843ad 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
+ uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
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@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
+ uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
# âšī¸ 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@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
+ uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
with:
category: '/language:${{matrix.language}}'
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
index 2a28b57569..823aa98fc8 100644
--- a/.github/workflows/docs-build.yml
+++ b/.github/workflows/docs-build.yml
@@ -85,7 +85,7 @@ jobs:
run: pnpm build
- name: Upload build output
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: docs-build-output
path: docs/build/
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index a74a2ec613..5d01646fef 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -174,7 +174,7 @@ 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 apply'
+ run: 'mise run //deployment:tf apply'
- name: Deploy Docs Subdomain Output
id: docs-output
@@ -186,7 +186,7 @@ jobs:
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: |
- mise run tf output -- -json | jq -r '
+ mise run //deployment:tf output -- -json | jq -r '
"projectName=\(.pages_project_name.value)",
"subdomain=\(.immich_app_branch_subdomain.value)"
' >> $GITHUB_OUTPUT
@@ -211,7 +211,7 @@ 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-release'
- run: 'mise run tf apply'
+ run: 'mise run //deployment:tf apply'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml
index 7de2d81858..3ad3f3558e 100644
--- a/.github/workflows/docs-destroy.yml
+++ b/.github/workflows/docs-destroy.yml
@@ -39,7 +39,7 @@ 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 destroy -- -refresh=false'
+ run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml
index 90810c2cfc..f7f34b929c 100644
--- a/.github/workflows/fix-format.yml
+++ b/.github/workflows/fix-format.yml
@@ -39,7 +39,7 @@ jobs:
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Fix formatting
- run: make install-all && make format-all
+ run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
- name: Commit and push
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index 4aa78ee13a..6e7ee8f608 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -62,7 +62,7 @@ jobs:
ref: main
- name: Install uv
- uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
+ uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -99,8 +99,23 @@ jobs:
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
+ # iOS secrets
+ APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
+ APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
+ APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
+ IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
+ IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
+ IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
+ IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
+ IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
+ IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
+ FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
+
with:
ref: ${{ needs.bump_version.outputs.ref }}
+ environment: production
prepare_release:
runs-on: ubuntu-latest
@@ -123,7 +138,7 @@ jobs:
persist-credentials: false
- name: Download APK
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8c7eae6532..44d7250f2f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -382,6 +382,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
+ submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -562,7 +563,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
- uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
+ uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
diff --git a/cli/mise.toml b/cli/mise.toml
new file mode 100644
index 0000000000..740184e03d
--- /dev/null
+++ b/cli/mise.toml
@@ -0,0 +1,29 @@
+[tasks.install]
+run = "pnpm install --filter @immich/cli --frozen-lockfile"
+
+[tasks.build]
+env._.path = "./node_modules/.bin"
+run = "vite build"
+
+[tasks.test]
+env._.path = "./node_modules/.bin"
+run = "vite"
+
+[tasks.lint]
+env._.path = "./node_modules/.bin"
+run = "eslint \"src/**/*.ts\" --max-warnings 0"
+
+[tasks."lint-fix"]
+run = { task = "lint --fix" }
+
+[tasks.format]
+env._.path = "./node_modules/.bin"
+run = "prettier --check ."
+
+[tasks."format-fix"]
+env._.path = "./node_modules/.bin"
+run = "prettier --write ."
+
+[tasks.check]
+env._.path = "./node_modules/.bin"
+run = "tsc --noEmit"
diff --git a/cli/package.json b/cli/package.json
index 0d635fce07..6fed806003 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
- "version": "2.2.98",
+ "version": "2.2.101",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -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.12",
+ "@types/node": "^22.19.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
diff --git a/deployment/mise.toml b/deployment/mise.toml
new file mode 100644
index 0000000000..f3d07ac31f
--- /dev/null
+++ b/deployment/mise.toml
@@ -0,0 +1,20 @@
+[tools]
+terragrunt = "0.91.2"
+opentofu = "1.10.6"
+
+[tasks."tg:fmt"]
+run = "terragrunt hclfmt"
+description = "Format terragrunt files"
+
+[tasks.tf]
+run = "terragrunt run --all"
+description = "Wrapper for terragrunt run-all"
+dir = "{{cwd}}"
+
+[tasks."tf:fmt"]
+run = "tofu fmt -recursive tf/"
+description = "Format terraform files"
+
+[tasks."tf:init"]
+run = { task = "tf init -- -reconfigure" }
+dir = "{{cwd}}"
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index a8c0de7454..e01f4ead22 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
- image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7
+ image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md
index fd9b8a5e4d..2b7527623f 100644
--- a/docs/docs/administration/postgres-standalone.md
+++ b/docs/docs/administration/postgres-standalone.md
@@ -10,16 +10,19 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
## Prerequisites
-You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`.
+You must install pgvector as it is a prerequisite for VectorChord.
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
-:::note
-Immich is known to work with Postgres versions `>= 14, < 18`.
+:::note Supported versions
+Immich is known to work with Postgres versions `>= 14, < 19`.
-Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`.
+VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
+
+The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
+The current accepted range for VectorChord is `>= 0.3, < 0.6`.
:::
## Specifying the connection URL
diff --git a/docs/docs/features/mobile-app.mdx b/docs/docs/features/mobile-app.mdx
index 2d34507a26..8b9a204741 100644
--- a/docs/docs/features/mobile-app.mdx
+++ b/docs/docs/features/mobile-app.mdx
@@ -10,6 +10,16 @@ import MobileAppBackup from '/docs/partials/_mobile-app-backup.md';
+:::info Android verification
+Below are the SHA-256 fingerprints for the certificates signing the android applications.
+
+- Playstore / Github releases:
+ `86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20`
+- F-Droid releases:
+ `FA:8B:43:95:F4:A6:47:71:A0:53:D1:C7:57:73:5F:A2:30:13:74:F5:3D:58:0D:D1:75:AA:F7:A1:35:72:9C:BF`
+
+:::
+
:::info Beta Program
The beta release channel allows users to test upcoming changes before they are officially released. To join the channel use the links below.
diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md
index 5cdcdc04c4..c2328d2bb8 100644
--- a/docs/docs/guides/database-queries.md
+++ b/docs/docs/guides/database-queries.md
@@ -106,14 +106,14 @@ SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset"
```sql title="Count by tag"
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
- JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id"
+ JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
```
```sql title="Count by tag (per user)"
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
- JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
+ JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
```
diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md
index 3fb0687e4a..a6aaae149b 100644
--- a/docs/docs/install/config-file.md
+++ b/docs/docs/install/config-file.md
@@ -16,48 +16,76 @@ The default configuration looks like this:
```json
{
- "ffmpeg": {
- "crf": 23,
- "threads": 0,
- "preset": "ultrafast",
- "targetVideoCodec": "h264",
- "acceptedVideoCodecs": ["h264"],
- "targetAudioCodec": "aac",
- "acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"],
- "acceptedContainers": ["mov", "ogg", "webm"],
- "targetResolution": "720",
- "maxBitrate": "0",
- "bframes": -1,
- "refs": 0,
- "gopSize": 0,
- "temporalAQ": false,
- "cqMode": "auto",
- "twoPass": false,
- "preferredHwDevice": "auto",
- "transcode": "required",
- "tonemap": "hable",
- "accel": "disabled",
- "accelDecode": false
- },
"backup": {
"database": {
- "enabled": true,
"cronExpression": "0 02 * * *",
+ "enabled": true,
"keepLastAmount": 14
}
},
+ "ffmpeg": {
+ "accel": "disabled",
+ "accelDecode": false,
+ "acceptedAudioCodecs": ["aac", "mp3", "libopus"],
+ "acceptedContainers": ["mov", "ogg", "webm"],
+ "acceptedVideoCodecs": ["h264"],
+ "bframes": -1,
+ "cqMode": "auto",
+ "crf": 23,
+ "gopSize": 0,
+ "maxBitrate": "0",
+ "preferredHwDevice": "auto",
+ "preset": "ultrafast",
+ "refs": 0,
+ "targetAudioCodec": "aac",
+ "targetResolution": "720",
+ "targetVideoCodec": "h264",
+ "temporalAQ": false,
+ "threads": 0,
+ "tonemap": "hable",
+ "transcode": "required",
+ "twoPass": false
+ },
+ "image": {
+ "colorspace": "p3",
+ "extractEmbedded": false,
+ "fullsize": {
+ "enabled": false,
+ "format": "jpeg",
+ "quality": 80
+ },
+ "preview": {
+ "format": "jpeg",
+ "quality": 80,
+ "size": 1440
+ },
+ "thumbnail": {
+ "format": "webp",
+ "quality": 80,
+ "size": 250
+ }
+ },
"job": {
"backgroundTask": {
"concurrency": 5
},
- "smartSearch": {
+ "faceDetection": {
"concurrency": 2
},
+ "library": {
+ "concurrency": 5
+ },
"metadataExtraction": {
"concurrency": 5
},
- "faceDetection": {
- "concurrency": 2
+ "migration": {
+ "concurrency": 5
+ },
+ "notifications": {
+ "concurrency": 5
+ },
+ "ocr": {
+ "concurrency": 1
},
"search": {
"concurrency": 5
@@ -65,20 +93,23 @@ The default configuration looks like this:
"sidecar": {
"concurrency": 5
},
- "library": {
- "concurrency": 5
- },
- "migration": {
- "concurrency": 5
+ "smartSearch": {
+ "concurrency": 2
},
"thumbnailGeneration": {
"concurrency": 3
},
"videoConversion": {
"concurrency": 1
+ }
+ },
+ "library": {
+ "scan": {
+ "cronExpression": "0 0 * * *",
+ "enabled": true
},
- "notifications": {
- "concurrency": 5
+ "watch": {
+ "enabled": false
}
},
"logging": {
@@ -86,8 +117,11 @@ The default configuration looks like this:
"level": "log"
},
"machineLearning": {
- "enabled": true,
- "urls": ["http://immich-machine-learning:3003"],
+ "availabilityChecks": {
+ "enabled": true,
+ "interval": 30000,
+ "timeout": 2000
+ },
"clip": {
"enabled": true,
"modelName": "ViT-B-32__openai"
@@ -96,27 +130,59 @@ The default configuration looks like this:
"enabled": true,
"maxDistance": 0.01
},
+ "enabled": true,
"facialRecognition": {
"enabled": true,
- "modelName": "buffalo_l",
- "minScore": 0.7,
"maxDistance": 0.5,
- "minFaces": 3
- }
+ "minFaces": 3,
+ "minScore": 0.7,
+ "modelName": "buffalo_l"
+ },
+ "ocr": {
+ "enabled": true,
+ "maxResolution": 736,
+ "minDetectionScore": 0.5,
+ "minRecognitionScore": 0.8,
+ "modelName": "PP-OCRv5_mobile"
+ },
+ "urls": ["http://immich-machine-learning:3003"]
},
"map": {
+ "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json",
"enabled": true,
- "lightStyle": "https://tiles.immich.cloud/v1/style/light.json",
- "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json"
- },
- "reverseGeocoding": {
- "enabled": true
+ "lightStyle": "https://tiles.immich.cloud/v1/style/light.json"
},
"metadata": {
"faces": {
"import": false
}
},
+ "newVersionCheck": {
+ "enabled": true
+ },
+ "nightlyTasks": {
+ "clusterNewFaces": true,
+ "databaseCleanup": true,
+ "generateMemories": true,
+ "missingThumbnails": true,
+ "startTime": "00:00",
+ "syncQuotaUsage": true
+ },
+ "notifications": {
+ "smtp": {
+ "enabled": false,
+ "from": "",
+ "replyTo": "",
+ "transport": {
+ "host": "",
+ "ignoreCert": false,
+ "password": "",
+ "port": 587,
+ "secure": false,
+ "username": ""
+ }
+ }
+ },
"oauth": {
"autoLaunch": false,
"autoRegister": true,
@@ -128,70 +194,44 @@ The default configuration looks like this:
"issuerUrl": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
+ "profileSigningAlgorithm": "none",
+ "roleClaim": "immich_role",
"scope": "openid email profile",
"signingAlgorithm": "RS256",
- "profileSigningAlgorithm": "none",
"storageLabelClaim": "preferred_username",
- "storageQuotaClaim": "immich_quota"
+ "storageQuotaClaim": "immich_quota",
+ "timeout": 30000,
+ "tokenEndpointAuthMethod": "client_secret_post"
},
"passwordLogin": {
"enabled": true
},
+ "reverseGeocoding": {
+ "enabled": true
+ },
+ "server": {
+ "externalDomain": "",
+ "loginPageMessage": "",
+ "publicUsers": true
+ },
"storageTemplate": {
"enabled": false,
"hashVerificationEnabled": true,
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
},
- "image": {
- "thumbnail": {
- "format": "webp",
- "size": 250,
- "quality": 80
- },
- "preview": {
- "format": "jpeg",
- "size": 1440,
- "quality": 80
- },
- "colorspace": "p3",
- "extractEmbedded": false
- },
- "newVersionCheck": {
- "enabled": true
- },
- "trash": {
- "enabled": true,
- "days": 30
+ "templates": {
+ "email": {
+ "albumInviteTemplate": "",
+ "albumUpdateTemplate": "",
+ "welcomeTemplate": ""
+ }
},
"theme": {
"customCss": ""
},
- "library": {
- "scan": {
- "enabled": true,
- "cronExpression": "0 0 * * *"
- },
- "watch": {
- "enabled": false
- }
- },
- "server": {
- "externalDomain": "",
- "loginPageMessage": ""
- },
- "notifications": {
- "smtp": {
- "enabled": false,
- "from": "",
- "replyTo": "",
- "transport": {
- "ignoreCert": false,
- "host": "",
- "port": 587,
- "username": "",
- "password": ""
- }
- }
+ "trash": {
+ "days": 30,
+ "enabled": true
},
"user": {
"deleteDelay": 7
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index 78a5289bf4..55c226d507 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -149,29 +149,31 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
-| Variable | Description | Default | Containers |
-| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
-| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
-| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
-| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
-| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
-| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
-| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
-| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning |
-| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning |
-| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
-| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
-| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
-| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
-| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
-| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning |
-| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
-| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
-| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
-| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
+| Variable | Description | Default | Containers |
+| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
+| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
+| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
+| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
+| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
+| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
+| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
+| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning |
+| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning |
+| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
+| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
+| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
+| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
+| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
+| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning |
+| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
+| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
+| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
+| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
+| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
+| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
diff --git a/docs/mise.toml b/docs/mise.toml
new file mode 100644
index 0000000000..4ffb7d5cce
--- /dev/null
+++ b/docs/mise.toml
@@ -0,0 +1,25 @@
+[tasks.install]
+run = "pnpm install --filter documentation --frozen-lockfile"
+
+[tasks.start]
+env._.path = "./node_modules/.bin"
+run = "docusaurus --port 3005"
+
+[tasks.build]
+env._.path = "./node_modules/.bin"
+run = [
+ "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
+ "docusaurus build",
+]
+
+[tasks.preview]
+env._.path = "./node_modules/.bin"
+run = "docusaurus serve"
+
+[tasks.format]
+env._.path = "./node_modules/.bin"
+run = "prettier --check ."
+
+[tasks."format-fix"]
+env._.path = "./node_modules/.bin"
+run = "prettier --write ."
diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json
index 8e4ad4c12a..6affb532c9 100644
--- a/docs/static/archived-versions.json
+++ b/docs/static/archived-versions.json
@@ -1,4 +1,16 @@
[
+ {
+ "label": "v2.2.3",
+ "url": "https://docs.v2.2.3.archive.immich.app"
+ },
+ {
+ "label": "v2.2.2",
+ "url": "https://docs.v2.2.2.archive.immich.app"
+ },
+ {
+ "label": "v2.2.1",
+ "url": "https://docs.v2.2.1.archive.immich.app"
+ },
{
"label": "v2.2.0",
"url": "https://docs.v2.2.0.archive.immich.app"
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index 9aef2288f6..3d62c8a34a 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -35,7 +35,7 @@ services:
- 2285:2285
redis:
- image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
+ image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
diff --git a/e2e/mise.toml b/e2e/mise.toml
new file mode 100644
index 0000000000..c298115e40
--- /dev/null
+++ b/e2e/mise.toml
@@ -0,0 +1,29 @@
+[tasks.install]
+run = "pnpm install --filter immich-e2e --frozen-lockfile"
+
+[tasks.test]
+env._.path = "./node_modules/.bin"
+run = "vitest --run"
+
+[tasks."test-web"]
+env._.path = "./node_modules/.bin"
+run = "playwright test"
+
+[tasks.format]
+env._.path = "./node_modules/.bin"
+run = "prettier --check ."
+
+[tasks."format-fix"]
+env._.path = "./node_modules/.bin"
+run = "prettier --write ."
+
+[tasks.lint]
+env._.path = "./node_modules/.bin"
+run = "eslint \"src/**/*.ts\" --max-warnings 0"
+
+[tasks."lint-fix"]
+run = { task = "lint --fix" }
+
+[tasks.check]
+env._.path = "./node_modules/.bin"
+run = "tsc --noEmit"
diff --git a/e2e/package.json b/e2e/package.json
index d6c875ad72..84e1823e0c 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
- "version": "2.2.0",
+ "version": "2.2.3",
"description": "",
"main": "index.js",
"type": "module",
@@ -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.12",
+ "@types/node": "^22.19.0",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts
index 5c30ff5cbe..ab3252c40b 100644
--- a/e2e/src/api/specs/asset.e2e-spec.ts
+++ b/e2e/src/api/specs/asset.e2e-spec.ts
@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
-import sharp from 'sharp';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
}) as DateTime;
const yesterday = today.minus({ days: 1 });
-const createTestImageWithExif = async (filename: string, exifData: Record) => {
- // Generate unique color to ensure different checksums for each image
- const r = Math.floor(Math.random() * 256);
- const g = Math.floor(Math.random() * 256);
- const b = Math.floor(Math.random() * 256);
-
- // Create a 100x100 solid color JPEG using Sharp
- const imageBytes = await sharp({
- create: {
- width: 100,
- height: 100,
- channels: 3,
- background: { r, g, b },
- },
- })
- .jpeg({ quality: 90 })
- .toBuffer();
-
- // Add random suffix to filename to avoid collisions
- const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
- const filepath = join(tempDir, uniqueFilename);
- await writeFile(filepath, imageBytes);
-
- // Filter out undefined values before writing EXIF
- const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
-
- await exiftool.write(filepath, cleanExifData);
-
- // Re-read the image bytes after EXIF has been written
- const finalImageBytes = await readFile(filepath);
-
- return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
-};
-
describe('/asset', () => {
let admin: LoginResponseDto;
let websocket: Socket;
@@ -1249,411 +1214,6 @@ describe('/asset', () => {
});
});
- describe('EXIF metadata extraction', () => {
- describe('Additional date tag extraction', () => {
- describe('Date-time vs time-only tag handling', () => {
- it('should fall back to file timestamps when only time-only tags are available', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
- TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
- // Exclude all date-time tags to force fallback to file timestamps
- SubSecDateTimeOriginal: undefined,
- DateTimeOriginal: undefined,
- SubSecCreateDate: undefined,
- SubSecMediaCreateDate: undefined,
- CreateDate: undefined,
- MediaCreateDate: undefined,
- CreationDate: undefined,
- DateTimeCreated: undefined,
- GPSDateTime: undefined,
- DateTimeUTC: undefined,
- SonyDateTime2: undefined,
- GPSDateStamp: undefined,
- });
-
- const oldDate = new Date('2020-01-01T00:00:00.000Z');
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- fileCreatedAt: oldDate.toISOString(),
- fileModifiedAt: oldDate.toISOString(),
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- // Should fall back to file timestamps, which we set to 2020-01-01
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2020-01-01T00:00:00.000Z').getTime(),
- );
- });
-
- it('should prefer DateTimeOriginal over time-only tags', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
- DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
- TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
- });
-
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- // Should use DateTimeOriginal, not TimeCreated
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2023-10-10T10:00:00.000Z').getTime(),
- );
- });
- });
-
- describe('GPSDateTime tag extraction', () => {
- it('should extract GPSDateTime with GPS coordinates', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
- GPSDateTime: '2023:11:15 12:30:00Z',
- GPSLatitude: 37.7749,
- GPSLongitude: -122.4194,
- // Exclude other date tags
- SubSecDateTimeOriginal: undefined,
- DateTimeOriginal: undefined,
- SubSecCreateDate: undefined,
- SubSecMediaCreateDate: undefined,
- CreateDate: undefined,
- MediaCreateDate: undefined,
- CreationDate: undefined,
- DateTimeCreated: undefined,
- TimeCreated: undefined,
- });
-
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
- expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2023-11-15T12:30:00.000Z').getTime(),
- );
- });
- });
-
- describe('CreateDate tag extraction', () => {
- it('should extract CreateDate when available', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
- CreateDate: '2023:11:15 10:30:00',
- // Exclude other higher priority date tags
- SubSecDateTimeOriginal: undefined,
- DateTimeOriginal: undefined,
- SubSecCreateDate: undefined,
- SubSecMediaCreateDate: undefined,
- MediaCreateDate: undefined,
- CreationDate: undefined,
- DateTimeCreated: undefined,
- TimeCreated: undefined,
- GPSDateTime: undefined,
- });
-
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2023-11-15T10:30:00.000Z').getTime(),
- );
- });
- });
-
- describe('GPSDateStamp tag extraction', () => {
- it('should fall back to file timestamps when only date-only tags are available', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
- GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
- // Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
- GPSLatitude: 51.5074,
- GPSLongitude: -0.1278,
- // Explicitly exclude all testable date-time tags to force fallback to file timestamps
- DateTimeOriginal: undefined,
- CreateDate: undefined,
- CreationDate: undefined,
- GPSDateTime: undefined,
- });
-
- const oldDate = new Date('2020-01-01T00:00:00.000Z');
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- fileCreatedAt: oldDate.toISOString(),
- fileModifiedAt: oldDate.toISOString(),
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
- expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
- // Should fall back to file timestamps, which we set to 2020-01-01
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2020-01-01T00:00:00.000Z').getTime(),
- );
- });
- });
-
- /*
- * NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
- *
- * NOT WRITABLE to JPEG:
- * - MediaCreateDate: Can be read from video files but not written to JPEG
- * - DateTimeCreated: Read-only tag in JPEG format
- * - DateTimeUTC: Cannot be written to JPEG files
- * - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
- * - SubSecMediaCreateDate: Tag not defined for JPEG format
- * - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
- *
- * WRITABLE but NOT READABLE from JPEG:
- * - SubSecDateTimeOriginal: Can be written but not read back from JPEG
- * - SubSecCreateDate: Can be written but not read back from JPEG
- *
- * EFFECTIVELY TESTABLE TAGS (writable and readable):
- * - DateTimeOriginal â
- * - CreateDate â
- * - CreationDate â
- * - GPSDateTime â
- *
- * The metadata service correctly handles non-readable tags and will fall back to
- * file timestamps when only non-readable tags are present.
- */
-
- describe('Date tag priority order', () => {
- it('should respect the complete date tag priority order', async () => {
- // Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
- const testCases = [
- {
- name: 'DateTimeOriginal has highest priority among testable tags',
- exifData: {
- DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
- CreateDate: '2023:05:05 05:00:00', // TESTABLE
- CreationDate: '2023:07:07 07:00:00', // TESTABLE
- GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
- },
- expectedDate: '2023-04-04T04:00:00.000Z',
- },
- {
- name: 'CreationDate when DateTimeOriginal missing',
- exifData: {
- CreationDate: '2023:05:05 05:00:00', // TESTABLE
- CreateDate: '2023:07:07 07:00:00', // TESTABLE
- GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
- },
- expectedDate: '2023-05-05T05:00:00.000Z',
- },
- {
- name: 'CreationDate when standard EXIF tags missing',
- exifData: {
- CreationDate: '2023:07:07 07:00:00', // TESTABLE
- GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
- },
- expectedDate: '2023-07-07T07:00:00.000Z',
- },
- {
- name: 'GPSDateTime when no other testable date tags present',
- exifData: {
- GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
- Make: 'SONY',
- },
- expectedDate: '2023-10-10T10:00:00.000Z',
- },
- ];
-
- for (const testCase of testCases) {
- const { imageBytes, filename } = await createTestImageWithExif(
- `${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
- testCase.exifData,
- );
-
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
- expect(
- new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
- `Date mismatch for: ${testCase.name}`,
- ).toBe(new Date(testCase.expectedDate).getTime());
- }
- });
- });
-
- describe('Edge cases for date tag handling', () => {
- it('should fall back to file timestamps with GPSDateStamp alone', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
- GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
- // Intentionally no GPSTimeStamp
- // Exclude all other date tags
- SubSecDateTimeOriginal: undefined,
- DateTimeOriginal: undefined,
- SubSecCreateDate: undefined,
- SubSecMediaCreateDate: undefined,
- CreateDate: undefined,
- MediaCreateDate: undefined,
- CreationDate: undefined,
- DateTimeCreated: undefined,
- TimeCreated: undefined,
- GPSDateTime: undefined,
- DateTimeUTC: undefined,
- });
-
- const oldDate = new Date('2020-01-01T00:00:00.000Z');
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- fileCreatedAt: oldDate.toISOString(),
- fileModifiedAt: oldDate.toISOString(),
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- // Should fall back to file timestamps, which we set to 2020-01-01
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2020-01-01T00:00:00.000Z').getTime(),
- );
- });
-
- it('should handle all testable date tags present to verify complete priority order', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
- // All TESTABLE date tags to JPEG format (writable AND readable)
- DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
- CreateDate: '2023:05:05 05:00:00', // TESTABLE
- CreationDate: '2023:07:07 07:00:00', // TESTABLE
- GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
- // Note: Excluded non-testable tags:
- // SubSec tags: writable but not readable from JPEG
- // Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
- // Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
- });
-
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- // Should use DateTimeOriginal as it has the highest priority among testable tags
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2023-04-04T04:00:00.000Z').getTime(),
- );
- });
-
- it('should use CreationDate when SubSec tags are missing', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
- CreationDate: '2023:07:07 07:00:00', // WRITABLE
- GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
- // Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
- // Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
- // Exclude SubSec and standard EXIF tags
- SubSecDateTimeOriginal: undefined,
- DateTimeOriginal: undefined,
- SubSecCreateDate: undefined,
- CreateDate: undefined,
- });
-
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- // Should use CreationDate when available
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2023-07-07T07:00:00.000Z').getTime(),
- );
- });
-
- it('should skip invalid date formats and use next valid tag', async () => {
- const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
- // Note: Testing invalid date handling with only WRITABLE tags
- GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
- CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
- // Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
- // Exclude other date tags
- SubSecDateTimeOriginal: undefined,
- DateTimeOriginal: undefined,
- SubSecCreateDate: undefined,
- CreateDate: undefined,
- });
-
- const asset = await utils.createAsset(admin.accessToken, {
- assetData: {
- filename,
- bytes: imageBytes,
- },
- });
-
- await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
-
- const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
-
- expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
- // Should skip invalid dates and use the first valid one (GPSDateTime)
- expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
- new Date('2023-10-10T10:00:00.000Z').getTime(),
- );
- });
- });
- });
- });
-
describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, {
diff --git a/e2e/src/generate-date-tag-test-images.ts b/e2e/src/generate-date-tag-test-images.ts
deleted file mode 100644
index 34cc956416..0000000000
--- a/e2e/src/generate-date-tag-test-images.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Script to generate test images with additional EXIF date tags
- * This creates actual JPEG images with embedded metadata for testing
- * Images are generated into e2e/test-assets/metadata/dates/
- */
-
-import { execSync } from 'node:child_process';
-import { writeFileSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import sharp from 'sharp';
-
-interface TestImage {
- filename: string;
- description: string;
- exifTags: Record;
-}
-
-const testImages: TestImage[] = [
- {
- filename: 'time-created.jpg',
- description: 'Image with TimeCreated tag',
- exifTags: {
- TimeCreated: '2023:11:15 14:30:00',
- Make: 'Canon',
- Model: 'EOS R5',
- },
- },
- {
- filename: 'gps-datetime.jpg',
- description: 'Image with GPSDateTime and coordinates',
- exifTags: {
- GPSDateTime: '2023:11:15 12:30:00Z',
- GPSLatitude: '37.7749',
- GPSLongitude: '-122.4194',
- GPSLatitudeRef: 'N',
- GPSLongitudeRef: 'W',
- },
- },
- {
- filename: 'datetime-utc.jpg',
- description: 'Image with DateTimeUTC tag',
- exifTags: {
- DateTimeUTC: '2023:11:15 10:30:00',
- Make: 'Nikon',
- Model: 'D850',
- },
- },
- {
- filename: 'gps-datestamp.jpg',
- description: 'Image with GPSDateStamp and GPSTimeStamp',
- exifTags: {
- GPSDateStamp: '2023:11:15',
- GPSTimeStamp: '08:30:00',
- GPSLatitude: '51.5074',
- GPSLongitude: '-0.1278',
- GPSLatitudeRef: 'N',
- GPSLongitudeRef: 'W',
- },
- },
- {
- filename: 'sony-datetime2.jpg',
- description: 'Sony camera image with SonyDateTime2 tag',
- exifTags: {
- SonyDateTime2: '2023:11:15 06:30:00',
- Make: 'SONY',
- Model: 'ILCE-7RM5',
- },
- },
- {
- filename: 'date-priority-test.jpg',
- description: 'Image with multiple date tags to test priority',
- exifTags: {
- SubSecDateTimeOriginal: '2023:01:01 01:00:00',
- DateTimeOriginal: '2023:02:02 02:00:00',
- SubSecCreateDate: '2023:03:03 03:00:00',
- CreateDate: '2023:04:04 04:00:00',
- CreationDate: '2023:05:05 05:00:00',
- DateTimeCreated: '2023:06:06 06:00:00',
- TimeCreated: '2023:07:07 07:00:00',
- GPSDateTime: '2023:08:08 08:00:00',
- DateTimeUTC: '2023:09:09 09:00:00',
- GPSDateStamp: '2023:10:10',
- SonyDateTime2: '2023:11:11 11:00:00',
- },
- },
- {
- filename: 'new-tags-only.jpg',
- description: 'Image with only additional date tags (no standard tags)',
- exifTags: {
- TimeCreated: '2023:12:01 15:45:30',
- GPSDateTime: '2023:12:01 13:45:30Z',
- DateTimeUTC: '2023:12:01 13:45:30',
- GPSDateStamp: '2023:12:01',
- SonyDateTime2: '2023:12:01 08:45:30',
- GPSLatitude: '40.7128',
- GPSLongitude: '-74.0060',
- GPSLatitudeRef: 'N',
- GPSLongitudeRef: 'W',
- },
- },
-];
-
-const generateTestImages = async (): Promise => {
- // Target directory: e2e/test-assets/metadata/dates/
- // Current file is in: e2e/src/
- const __filename = fileURLToPath(import.meta.url);
- const __dirname = dirname(__filename);
- const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
-
- console.log('Generating test images with additional EXIF date tags...');
- console.log(`Target directory: ${targetDir}`);
-
- for (const image of testImages) {
- try {
- const imagePath = join(targetDir, image.filename);
-
- // Create unique JPEG file using Sharp
- const r = Math.floor(Math.random() * 256);
- const g = Math.floor(Math.random() * 256);
- const b = Math.floor(Math.random() * 256);
-
- const jpegData = await sharp({
- create: {
- width: 100,
- height: 100,
- channels: 3,
- background: { r, g, b },
- },
- })
- .jpeg({ quality: 90 })
- .toBuffer();
-
- writeFileSync(imagePath, jpegData);
-
- // Build exiftool command to add EXIF data
- const exifArgs = Object.entries(image.exifTags)
- .map(([tag, value]) => `-${tag}="${value}"`)
- .join(' ');
-
- const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
-
- console.log(`Creating ${image.filename}: ${image.description}`);
- execSync(command, { stdio: 'pipe' });
-
- // Verify the tags were written
- const verifyCommand = `exiftool -json "${imagePath}"`;
- const result = execSync(verifyCommand, { encoding: 'utf8' });
- const metadata = JSON.parse(result)[0];
-
- console.log(` â Created with ${Object.keys(image.exifTags).length} EXIF tags`);
-
- // Log first date tag found for verification
- const firstDateTag = Object.keys(image.exifTags).find(
- (tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
- );
- if (firstDateTag && metadata[firstDateTag]) {
- console.log(` â Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
- }
- } catch (error) {
- console.error(`Failed to create ${image.filename}:`, (error as Error).message);
- }
- }
-
- console.log('\nTest image generation complete!');
- console.log('Files created in:', targetDir);
- console.log('\nTo test these images:');
- console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
-};
-
-export { generateTestImages };
-
-// Run the generator if this file is executed directly
-if (import.meta.url === `file://${process.argv[1]}`) {
- generateTestImages().catch(console.error);
-}
diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts
index 3d64e47aef..611a1b3dec 100644
--- a/e2e/src/web/specs/user-admin.e2e-spec.ts
+++ b/e2e/src/web/specs/user-admin.e2e-spec.ts
@@ -52,7 +52,7 @@ test.describe('User Administration', () => {
await page.goto(`/admin/users/${user.userId}`);
- await page.getByRole('button', { name: 'Edit user' }).click();
+ await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByText('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
@@ -77,7 +77,7 @@ test.describe('User Administration', () => {
await page.goto(`/admin/users/${user.userId}`);
- await page.getByRole('button', { name: 'Edit user' }).click();
+ await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByText('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
diff --git a/e2e/test-assets b/e2e/test-assets
index 37f60ea537..163c251744 160000
--- a/e2e/test-assets
+++ b/e2e/test-assets
@@ -1 +1 @@
-Subproject commit 37f60ea537c0228f5f92e4f42dc42f0bb39a6d7f
+Subproject commit 163c251744e0a35d7ecfd02682452043f149fc2b
diff --git a/i18n/da.json b/i18n/da.json
index 84109939bf..5bb67dd83d 100644
--- a/i18n/da.json
+++ b/i18n/da.json
@@ -1716,6 +1716,7 @@
"running": "Kører",
"save": "Gem",
"save_to_gallery": "Gem til galleri",
+ "saved": "Gemt",
"saved_api_key": "Gemt API-nøgle",
"saved_profile": "Gemte profil",
"saved_settings": "Gemte indstillinger",
diff --git a/i18n/de.json b/i18n/de.json
index ce0f8a2966..0cae9c0762 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -155,12 +155,15 @@
"machine_learning_min_recognized_faces": "Mindestens erkannte Gesichter",
"machine_learning_min_recognized_faces_description": "Die Mindestanzahl von erkannten Gesichtern, damit eine Person erstellt werden kann. Eine ErhÃļhung dieses Wertes macht die Gesichtserkennung präziser, erhÃļht aber die Wahrscheinlichkeit, dass ein Gesicht nicht zu einer Person zugeordnet wird.",
"machine_learning_ocr": "OCR",
- "machine_learning_ocr_description": "Maschinen lernen nutzen um Texte in Bildern zu erkennen",
+ "machine_learning_ocr_description": "Maschinelles Lernen nutzen um Texte in Bildern zu erkennen",
"machine_learning_ocr_enabled": "OCR aktivieren",
"machine_learning_ocr_enabled_description": "Wenn deaktiviert, werden die Bilder nicht von der Texterkennung bearbeitet.",
"machine_learning_ocr_max_resolution": "Maximale AuflÃļsung",
"machine_learning_ocr_max_resolution_description": "Vorschauen Ãŧber dieser AuflÃļsung werden unter Beibehaltung des Seitenverhältnisses verkleinert. HÃļhere Werte sind genauer, benÃļtigen jedoch mehr Zeit fÃŧr die Verarbeitung und verbrauchen mehr Speicher.",
"machine_learning_ocr_min_detection_score": "Minimaler Erkennungswert",
+ "machine_learning_ocr_min_detection_score_description": "Minimale Konfidenzrate fÃŧr die Texterkennung von 0â1. Niedrigere Werte fÃŧhren dazu, dass mehr Text erkannt wird, kÃļnnen jedoch zu falsch-positiven Ergebnissen fÃŧhren.",
+ "machine_learning_ocr_min_recognition_score": "Minimale Erkennungsrate",
+ "machine_learning_ocr_min_score_recognition_description": "Minimale Konfidenzrate fÃŧr die Erkennung von erkanntem Text von 0â1. Niedrigere Werte fÃŧhren dazu, dass mehr Text erkannt wird, kÃļnnen jedoch zu falsch-positiven Ergebnissen fÃŧhren.",
"machine_learning_ocr_model": "OCR Modell",
"machine_learning_ocr_model_description": "Server Modelle sind genauer als mobile Modelle, brauchen aber länger zur Verarbeitung und brauchen mehr Speicher.",
"machine_learning_settings": "Einstellungen fÃŧr maschinelles Lernen",
@@ -254,7 +257,7 @@
"oauth_storage_quota_default_description": "Kontingent in GiB, das verwendet werden soll, wenn keines Ãŧbermittelt wird.",
"oauth_timeout": "ZeitÃŧberschreitung bei Anfrage",
"oauth_timeout_description": "ZeitÃŧberschreitung fÃŧr Anfragen in Millisekunden",
- "ocr_job_description": "Verwende Machine Learning zur Ernennung von Text in Bildern",
+ "ocr_job_description": "Verwende Machine Learning zur Erkennung von Text in Bildern",
"password_enable_description": "Mit E-Mail und Passwort anmelden",
"password_settings": "Passwort-Anmeldung",
"password_settings_description": "Passwort-Anmeldeeinstellungen verwalten",
@@ -1352,7 +1355,7 @@
"memories_check_back_tomorrow": "Schau morgen wieder vorbei fÃŧr weitere Erinnerungen",
"memories_setting_description": "Verwalte, was du in deinen Erinnerungen siehst",
"memories_start_over": "Erneut beginnen",
- "memories_swipe_to_close": "Nach oben Wischen zum schlieÃen",
+ "memories_swipe_to_close": "Nach oben Wischen zum SchlieÃen",
"memory": "Erinnerung",
"memory_lane_title": "Foto-Erinnerungen {title}",
"menu": "MenÃŧ",
@@ -1713,6 +1716,7 @@
"running": "Läuft",
"save": "Speichern",
"save_to_gallery": "In Galerie speichern",
+ "saved": "Gespeichert",
"saved_api_key": "API-SchlÃŧssel wurde gespeichert",
"saved_profile": "Profil gespeichert",
"saved_settings": "Einstellungen gespeichert",
diff --git a/i18n/en.json b/i18n/en.json
index 2daf6187bb..1776add03a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -479,6 +479,7 @@
"allow_edits": "Allow edits",
"allow_public_user_to_download": "Allow public user to download",
"allow_public_user_to_upload": "Allow public user to upload",
+ "allowed": "Allowed",
"alt_text_qr_code": "QR code image",
"anti_clockwise": "Anti-clockwise",
"api_key": "API Key",
@@ -1200,6 +1201,8 @@
"import_path": "Import path",
"in_albums": "In {count, plural, one {# album} other {# albums}}",
"in_archive": "In archive",
+ "in_year": "In {year}",
+ "in_year_selector": "In",
"include_archived": "Include archived",
"include_shared_albums": "Include shared albums",
"include_shared_partner_assets": "Include shared partner assets",
@@ -1236,6 +1239,7 @@
"language_setting_description": "Select your preferred language",
"large_files": "Large Files",
"last": "Last",
+ "last_months": "{count, plural, one {Last month} other {Last # months}}",
"last_seen": "Last seen",
"latest_version": "Latest Version",
"latitude": "Latitude",
@@ -1323,6 +1327,10 @@
"maintenance_title": "Temporarily Unavailable",
"make": "Make",
"manage_geolocation": "Manage location",
+ "manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
+ "manage_media_access_settings": "Open settings",
+ "manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
+ "manage_media_access_title": "Media Management Access",
"manage_shared_links": "Manage shared links",
"manage_sharing_with_partners": "Manage sharing with partners",
"manage_the_app_settings": "Manage the app settings",
@@ -1415,6 +1423,7 @@
"new_pin_code": "New PIN code",
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
"new_timeline": "New Timeline",
+ "new_update": "New update",
"new_user_created": "New user created",
"new_version_available": "NEW VERSION AVAILABLE",
"newest_first": "Newest first",
@@ -1430,6 +1439,7 @@
"no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets",
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
+ "no_devices": "No authorized devices",
"no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
@@ -1446,6 +1456,7 @@
"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_uploads_in_progress": "No uploads in progress",
+ "not_allowed": "Not allowed",
"not_available": "N/A",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
@@ -1556,6 +1567,8 @@
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
"photos_from_previous_years": "Photos from previous years",
"pick_a_location": "Pick a location",
+ "pick_custom_range": "Custom range",
+ "pick_date_range": "Select a date range",
"pin_code_changed_successfully": "Successfully changed PIN code",
"pin_code_reset_successfully": "Successfully reset PIN code",
"pin_code_setup_successfully": "Successfully setup a PIN code",
@@ -2038,6 +2051,7 @@
"third_party_resources": "Third-Party Resources",
"time": "Time",
"time_based_memories": "Time-based memories",
+ "time_based_memories_duration": "Number of seconds to display each image.",
"timeline": "Timeline",
"timezone": "Timezone",
"to_archive": "Archive",
diff --git a/i18n/fr.json b/i18n/fr.json
index 6c2f979e86..789afffc38 100644
--- a/i18n/fr.json
+++ b/i18n/fr.json
@@ -157,7 +157,7 @@
"machine_learning_ocr": "OCR",
"machine_learning_ocr_description": "Utiliser l'apprentissage automatique pour reconnaÃŽtre le texte dans les images",
"machine_learning_ocr_enabled": "Activer la reconnaissance de caractères",
- "machine_learning_ocr_enabled_description": "Si dÊsactivÊ, la reconnaissance de texte ne s'appliquera pas aux images",
+ "machine_learning_ocr_enabled_description": "Si dÊsactivÊ, la reconnaissance de texte ne s'appliquera pas aux images.",
"machine_learning_ocr_max_resolution": "RÊsolution maximale",
"machine_learning_ocr_max_resolution_description": "Les prÊvisualisations au-dessus de cette rÊsolution seront retaillÊes en conservant leur ratio. Des valeurs plus grandes sont plus prÊcises, mais sont plus lentes et utilisent plus de mÊmoire.",
"machine_learning_ocr_min_detection_score": "Score minimum de dÊtection",
diff --git a/i18n/hi.json b/i18n/hi.json
index d902a34dd8..fb1698c0a2 100644
--- a/i18n/hi.json
+++ b/i18n/hi.json
@@ -33,6 +33,7 @@
"add_to_albums": "ā¤ā¤ā¤žā¤§ā¤ŋ⤠ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛āĨ",
"add_to_albums_count": "ā¤ā¤˛āĨā¤Ŧā¤ŽāĨā¤ ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛āĨ⤠({count})",
"add_to_shared_album": "ā¤ļāĨ⤝⤰ ā¤ā¤ŋ⤠ā¤ā¤ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛āĨā¤",
+ "add_upload_to_stack": "⤏āĨā¤āĨā¤ ā¤ŽāĨ⤠ā¤
ā¤Ē⤞āĨā¤Ą ā¤ā¤°āĨā¤",
"add_url": "URL ā¤Ąā¤žā¤˛āĨā¤",
"added_to_archive": "⤏ā¤ā¤āĨā¤°ā¤šāĨ⤤ ā¤ā¤° ā¤Ļā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž ā¤šāĨ",
"added_to_favorites": "ā¤Ē⤏ā¤ā¤ĻāĨā¤Ļā¤ž ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛ā¤ž ā¤ā¤¯ā¤ž",
@@ -124,6 +125,13 @@
"logging_enable_description": "⤞āĨā¤ā¤ŋā¤ā¤ ā¤ā¤°ā¤¨āĨ ā¤ĻāĨā¤¨ā¤ž",
"logging_level_description": "⤏ā¤āĨā¤ˇā¤Ž ā¤šāĨ⤍āĨ ā¤Ē⤰, ā¤ā¤ŋ⤏ ⤞āĨ⤠⤏āĨ⤤⤰ ā¤ā¤ž ā¤ā¤Ē⤝āĨ⤠ā¤ā¤°ā¤¨ā¤ž ā¤šāĨāĨ¤",
"logging_settings": "⤞āĨā¤ā¤ŋā¤ā¤",
+ "machine_learning_availability_checks": "ā¤ā¤Ē⤞ā¤ŦāĨā¤§ā¤¤ā¤ž ā¤ā¤žā¤ā¤",
+ "machine_learning_availability_checks_description": "ā¤ā¤Ē⤞ā¤ŦāĨ⤧ ā¤Žā¤ļāĨ⤍ ⤞⤰āĨ⤍ā¤ŋā¤ā¤ ⤏⤰āĨā¤ĩ⤰ ā¤ā¤ž ⤏āĨā¤ĩā¤ā¤žā¤˛ā¤ŋ⤤ ⤰āĨā¤Ē ⤏āĨ ā¤Ēā¤¤ā¤ž ⤞ā¤ā¤žā¤ā¤ ā¤ā¤° ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋā¤ā¤¤ā¤ž ā¤ĻāĨā¤",
+ "machine_learning_availability_checks_enabled": "ā¤ā¤Ē⤞ā¤ŦāĨā¤§ā¤¤ā¤ž ā¤ā¤žā¤ā¤ ⤏ā¤āĨā¤ˇā¤Ž ā¤ā¤°āĨā¤",
+ "machine_learning_availability_checks_interval": "ā¤
ā¤ā¤¤ā¤°ā¤žā¤˛ ā¤āĨ ā¤ā¤žā¤ā¤ ā¤ā¤°āĨā¤",
+ "machine_learning_availability_checks_interval_description": "ā¤ā¤Ē⤞ā¤ŦāĨā¤§ā¤¤ā¤ž ā¤ā¤žā¤ā¤ ā¤āĨ ā¤ŦāĨā¤ ā¤Žā¤ŋ⤞āĨ⤏āĨā¤āĨā¤ā¤Ą ā¤ŽāĨ⤠ā¤
ā¤ā¤¤ā¤°ā¤žā¤˛",
+ "machine_learning_availability_checks_timeout": "ā¤
⤍āĨ⤰āĨ⤧ ā¤¸ā¤Žā¤¯ā¤Ŧā¤žā¤šāĨ⤝ ā¤šāĨā¤",
+ "machine_learning_availability_checks_timeout_description": "ā¤ā¤Ē⤞ā¤ŦāĨā¤§ā¤¤ā¤ž ā¤ā¤žā¤ā¤ ā¤āĨ ⤞ā¤ŋā¤ ā¤Žā¤ŋ⤞āĨ⤏āĨā¤ā¤ā¤Ą ā¤ŽāĨā¤ ā¤¸ā¤Žā¤¯ā¤Ŧā¤žā¤šāĨ⤝ ā¤
ā¤ā¤¤ā¤°ā¤žā¤˛",
"machine_learning_clip_model": "ā¤āĨ⤞ā¤ŋā¤Ē ā¤ŽāĨā¤Ąā¤˛",
"machine_learning_clip_model_description": "CLIP ā¤ŽāĨā¤Ąā¤˛ ā¤ā¤ž ā¤¨ā¤žā¤Ž ā¤¯ā¤šā¤žā¤ ā¤¸āĨā¤āĨā¤Ŧā¤ĻāĨ⤧ ā¤šāĨāĨ¤ ⤧āĨā¤¯ā¤žā¤¨ ā¤ĻāĨ⤠ā¤ā¤ŋ ā¤ŽāĨā¤Ąā¤˛ ā¤Ŧā¤Ļ⤞⤍āĨ ā¤Ē⤰ ā¤ā¤Ēā¤āĨ ⤏ā¤āĨ ā¤ā¤ĩā¤ŋ⤝āĨ⤠ā¤āĨ ⤞ā¤ŋ⤠'⤏āĨā¤Žā¤žā¤°āĨ⤠⤏⤰āĨā¤' ā¤āĨā¤Ŧ ā¤Ģā¤ŋ⤰ ⤏āĨ ā¤ā¤˛ā¤žā¤¨ā¤ž ā¤šāĨā¤ā¤žāĨ¤",
"machine_learning_duplicate_detection": "ā¤ĄāĨā¤ĒāĨ⤞ā¤ŋā¤āĨ⤠ā¤ā¤ž ā¤Ēā¤¤ā¤ž ⤞ā¤ā¤žā¤¨ā¤ž",
@@ -146,6 +154,18 @@
"machine_learning_min_detection_score_description": "ā¤ā¤ŋ⤏āĨ ā¤āĨā¤šā¤°āĨ ā¤ā¤ž ā¤Ēā¤¤ā¤ž ⤞ā¤ā¤žā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠⤍āĨ⤝āĨā¤¨ā¤¤ā¤Ž ā¤ā¤¤āĨā¤Žā¤ĩā¤ŋā¤ļāĨā¤ĩā¤žā¤¸ ⤏āĨā¤āĨ⤰ 0-1 ā¤šāĨā¤¨ā¤ž ā¤ā¤žā¤šā¤ŋā¤āĨ¤",
"machine_learning_min_recognized_faces": "⤍ā¤ŋā¤ŽāĨā¤¨ā¤¤ā¤Ž ā¤Ēā¤šā¤ā¤žā¤¨āĨ ā¤āĨā¤šā¤°āĨ",
"machine_learning_min_recognized_faces_description": "ā¤ā¤ŋ⤏āĨ ā¤ĩāĨ⤝ā¤āĨ⤤ā¤ŋ ā¤āĨ ⤞ā¤ŋ⤠ā¤Ēā¤šā¤ā¤žā¤¨āĨ ā¤ā¤žā¤¨āĨ ā¤ĩā¤žā¤˛āĨ ā¤āĨā¤šā¤°āĨ⤠ā¤āĨ ⤍āĨ⤝āĨā¤¨ā¤¤ā¤Ž ⤏ā¤ā¤āĨā¤¯ā¤žāĨ¤",
+ "machine_learning_ocr": "ā¤.⤏āĨ.ā¤ā¤°",
+ "machine_learning_ocr_description": "ā¤ā¤ŋ⤤āĨ⤰āĨā¤ ā¤ŽāĨ⤠ā¤Ēā¤žā¤ ā¤āĨ ā¤Ēā¤šā¤ā¤žā¤¨ā¤¨āĨ ā¤āĨ ⤞ā¤ŋā¤ ā¤Žā¤ļāĨ⤍ ⤞⤰āĨ⤍ā¤ŋā¤ā¤ ā¤ā¤ž ā¤ā¤Ē⤝āĨ⤠ā¤ā¤°āĨā¤",
+ "machine_learning_ocr_enabled": "ā¤.⤏āĨ.ā¤ā¤°. ⤏ā¤āĨā¤ˇā¤Ž ā¤ā¤°āĨā¤",
+ "machine_learning_ocr_enabled_description": "⤝ā¤Ļā¤ŋ ā¤
ā¤āĨā¤ˇā¤Ž ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž ā¤šāĨ, ⤤āĨ ā¤ā¤ŋ⤤āĨ⤰āĨ⤠ā¤Ē⤰ ā¤Ēā¤žā¤ -ā¤Ēā¤šā¤ā¤žā¤¨ ā¤¨ā¤šāĨā¤ ā¤šāĨā¤ā¤žāĨ¤",
+ "machine_learning_ocr_max_resolution": "ā¤
⤧ā¤ŋā¤ā¤¤ā¤Ž ⤰ā¤ŋā¤ā¤ŧāĨ⤞āĨ⤝āĨā¤ļ⤍",
+ "machine_learning_ocr_max_resolution_description": "ā¤ā¤¸ ⤰ā¤ŋā¤ā¤ŧāĨ⤞āĨ⤝āĨā¤ļ⤍ ⤏āĨ ā¤ā¤Ē⤰ ā¤āĨ ā¤ĒāĨ⤰ā¤Ļ⤰āĨā¤ļ⤍ ā¤ā¤ž ā¤ā¤ā¤žā¤° ā¤ŽāĨ⤞ ā¤
⤍āĨā¤Ēā¤žā¤¤ ā¤āĨ ⤏ā¤ā¤°ā¤āĨ⤎ā¤ŋ⤤ ā¤ā¤°ā¤¤āĨ ā¤šāĨ⤠ā¤Ŧā¤Ļ⤞ ā¤Ļā¤ŋā¤¯ā¤ž ā¤ā¤žā¤ā¤ā¤žāĨ¤ ā¤ā¤āĨā¤ ā¤Žā¤žā¤¨ ā¤
⤧ā¤ŋ⤠⤏ā¤āĨā¤ ā¤šāĨ⤤āĨ ā¤šāĨā¤, ⤞āĨā¤ā¤ŋ⤍ ⤏ā¤ā¤¸ā¤žā¤§ā¤ŋ⤤ ā¤šāĨ⤍āĨ ā¤ŽāĨ⤠ā¤
⤧ā¤ŋā¤ ā¤ŽāĨā¤ŽāĨ⤰āĨ ā¤ā¤° ā¤¸ā¤Žā¤¯ ⤞ā¤ā¤žā¤¤āĨ ā¤šāĨā¤āĨ¤",
+ "machine_learning_ocr_min_detection_score": "⤍āĨ⤝āĨā¤¨ā¤¤ā¤Ž ā¤āĨ⤠ā¤
ā¤ā¤",
+ "machine_learning_ocr_min_detection_score_description": "ā¤Ēā¤žā¤ ā¤ā¤ž ā¤Ēā¤¤ā¤ž ⤞ā¤ā¤žā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠0-1 ā¤āĨ ā¤ŦāĨ⤠⤍āĨ⤝āĨā¤¨ā¤¤ā¤Ž ā¤ā¤¤āĨā¤Žā¤ĩā¤ŋā¤ļāĨā¤ĩā¤žā¤¸ ā¤
ā¤ā¤āĨ¤ ā¤ā¤Ž ā¤
ā¤ā¤ ā¤
⤧ā¤ŋ⤠ā¤Ēā¤žā¤ ā¤ā¤ž ā¤Ēā¤¤ā¤ž ⤞ā¤ā¤žā¤ā¤ā¤āĨ ⤞āĨā¤ā¤ŋ⤍ ā¤Ē⤰ā¤ŋā¤Ŗā¤žā¤Ž ā¤ā¤˛ā¤¤ ā¤šāĨ ⤏ā¤ā¤¤āĨ ā¤šāĨā¤āĨ¤",
+ "machine_learning_ocr_min_recognition_score": "⤍āĨ⤝āĨā¤¨ā¤¤ā¤Ž ā¤Ēā¤šā¤ā¤žā¤¨ ā¤
ā¤ā¤",
+ "machine_learning_ocr_min_score_recognition_description": "ā¤Ēā¤žā¤ ā¤āĨ ā¤Ēā¤šā¤ā¤žā¤¨ā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠0-1 ā¤āĨ ā¤ŦāĨ⤠⤍āĨ⤝āĨā¤¨ā¤¤ā¤Ž ā¤ā¤¤āĨā¤Žā¤ĩā¤ŋā¤ļāĨā¤ĩā¤žā¤¸ ā¤
ā¤ā¤āĨ¤ ā¤ā¤Ž ā¤
ā¤ā¤ ā¤
⤧ā¤ŋ⤠ā¤Ēā¤žā¤ ā¤āĨ ā¤Ēā¤šā¤ā¤žā¤¨āĨā¤ā¤āĨ ⤞āĨā¤ā¤ŋ⤍ ā¤Ē⤰ā¤ŋā¤Ŗā¤žā¤Ž ā¤ā¤˛ā¤¤ ā¤šāĨ ⤏ā¤ā¤¤āĨ ā¤šāĨā¤āĨ¤",
+ "machine_learning_ocr_model": "ā¤ā¤¸āĨā¤ā¤° ā¤ĒāĨ⤰⤤ā¤ŋā¤Žā¤žā¤¨",
+ "machine_learning_ocr_model_description": "⤏⤰āĨā¤ĩ⤰ ā¤ĒāĨ⤰⤤ā¤ŋā¤Žā¤žā¤¨ ā¤ŽāĨā¤Ŧā¤žā¤ā¤˛ ā¤ĒāĨ⤰⤤ā¤ŋā¤Žā¤žā¤¨ ā¤āĨ ⤤āĨā¤˛ā¤¨ā¤ž ā¤ŽāĨ⤠ā¤
⤧ā¤ŋ⤠⤏ā¤āĨā¤ ā¤šāĨ⤤āĨ ā¤šāĨā¤, ⤞āĨā¤ā¤ŋ⤍ ⤏ā¤ā¤¸ā¤žā¤§ā¤ŋ⤤ ā¤šāĨ⤍āĨ ā¤ŽāĨ⤠ā¤
⤧ā¤ŋā¤ ā¤ŽāĨā¤ŽāĨ⤰āĨ ā¤ā¤° ā¤¸ā¤Žā¤¯ ⤞āĨ⤤āĨ ā¤šāĨā¤āĨ¤",
"machine_learning_settings": "ā¤Žā¤ļāĨ⤍ ⤞⤰āĨ⤍ā¤ŋā¤ā¤ ⤏āĨā¤ā¤ŋā¤ā¤āĨ⤏",
"machine_learning_settings_description": "ā¤Žā¤ļāĨ⤍ ⤞⤰āĨ⤍ā¤ŋā¤ā¤ ⤏āĨā¤ĩā¤ŋā¤§ā¤žā¤ā¤ ā¤ā¤° ⤏āĨā¤ā¤ŋā¤ā¤āĨ⤏ ā¤āĨ ā¤ĒāĨ⤰ā¤Ŧā¤ā¤§ā¤ŋ⤤ ā¤ā¤°āĨā¤",
"machine_learning_smart_search": "⤏āĨā¤Žā¤žā¤°āĨ⤠ā¤āĨā¤",
@@ -203,6 +223,8 @@
"notification_email_ignore_certificate_errors_description": "ā¤āĨā¤ā¤˛ā¤ā¤¸ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨ⤰ ⤏⤤āĨā¤¯ā¤žā¤Ē⤍ ⤤āĨ⤰āĨā¤ā¤ŋ⤝āĨ⤠ā¤Ē⤰ ⤧āĨā¤¯ā¤žā¤¨ ⤍ ā¤ĻāĨ⤠(ā¤
⤍āĨā¤ļā¤ā¤¸ā¤ŋ⤤ ā¤¨ā¤šāĨā¤)",
"notification_email_password_description": "ā¤ā¤ŽāĨ⤞ ⤏⤰āĨā¤ĩ⤰ ⤏āĨ ā¤ĒāĨā¤°ā¤Žā¤žā¤ŖāĨā¤ā¤°ā¤Ŗ ā¤ā¤°ā¤¤āĨ ā¤¸ā¤Žā¤¯ ā¤ā¤Ē⤝āĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤žā¤¨āĨ ā¤ĩā¤žā¤˛ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą",
"notification_email_port_description": "ā¤ā¤ŽāĨ⤞ ⤏⤰āĨā¤ĩ⤰ ā¤ā¤ž ā¤ĒāĨ⤰āĨ⤠(ā¤āĨ⤏āĨ 25, 465, ā¤¯ā¤ž 587)",
+ "notification_email_secure": "ā¤ā¤¸ ā¤ā¤Ž ā¤āĨ ā¤ĒāĨ ā¤ā¤¸",
+ "notification_email_secure_description": "ā¤ā¤¸.ā¤ā¤Ž.ā¤āĨ.ā¤ĒāĨ.ā¤ā¤¸. ā¤ĒāĨ⤰⤝āĨ⤠ā¤ā¤°āĨ⤠(ā¤āĨ.ā¤ā¤˛.ā¤ā¤¸ ā¤Ē⤰ ā¤ā¤¸.ā¤ā¤Ž.ā¤āĨ.ā¤ĒāĨ)",
"notification_email_sent_test_email_button": "ā¤Ē⤰āĨā¤āĨ⤎⤪ ā¤ā¤ŽāĨ⤞ ā¤āĨā¤āĨ⤠ā¤ā¤° ā¤¸ā¤šāĨā¤āĨā¤",
"notification_email_setting_description": "ā¤ā¤ŽāĨ⤞ ⤏āĨā¤ā¤¨ā¤žā¤ā¤ ā¤āĨā¤ā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠⤏āĨā¤ā¤ŋā¤ā¤āĨ⤏",
"notification_email_test_email": "ā¤Ē⤰āĨā¤āĨ⤎⤪ ā¤ā¤ŽāĨ⤞ ā¤āĨā¤āĨā¤",
@@ -235,6 +257,7 @@
"oauth_storage_quota_default_description": "GiB ā¤ŽāĨ⤠ā¤āĨā¤ā¤ž ā¤ā¤ž ā¤ā¤Ē⤝āĨ⤠⤤ā¤Ŧ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤žā¤ā¤ā¤ž ā¤ā¤Ŧ ā¤āĨ⤠ā¤Ļā¤žā¤ĩā¤ž ā¤ĒāĨ⤰ā¤Ļā¤žā¤¨ ā¤¨ā¤šāĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž ā¤šāĨ āĨ¤",
"oauth_timeout": "ā¤ŦāĨ⤰āĨ⤠ā¤ā¤ž ā¤
⤍āĨ⤰āĨ⤧",
"oauth_timeout_description": "ā¤
⤍āĨ⤰āĨ⤧āĨ⤠ā¤āĨ ⤞ā¤ŋā¤ ā¤¸ā¤Žā¤¯-⤏āĨā¤Žā¤ž ā¤Žā¤ŋ⤞āĨ⤏āĨā¤ā¤ā¤Ą ā¤ŽāĨā¤",
+ "ocr_job_description": "ā¤ā¤ŋ⤤āĨ⤰āĨā¤ ā¤ŽāĨ⤠ā¤Ēā¤žā¤ ā¤āĨ ā¤Ēā¤šā¤ā¤žā¤¨ā¤¨āĨ ā¤āĨ ⤞ā¤ŋā¤ ā¤Žā¤ļāĨ⤍ ⤞⤰āĨ⤍ā¤ŋā¤ā¤ ā¤ā¤ž ā¤ā¤Ē⤝āĨ⤠ā¤ā¤°āĨā¤",
"password_enable_description": "ā¤ā¤ŽāĨ⤞ ā¤ā¤° ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ⤏āĨ ⤞āĨā¤ā¤ŋ⤍ ā¤ā¤°āĨā¤",
"password_settings": "ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ⤞āĨ⤠ā¤ā¤¨",
"password_settings_description": "ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ⤞āĨā¤ā¤ŋ⤍ ⤏āĨā¤ā¤ŋā¤ā¤ ā¤ĒāĨ⤰ā¤Ŧā¤ā¤§ā¤ŋ⤤ ā¤ā¤°āĨā¤",
@@ -325,7 +348,7 @@
"transcoding_max_b_frames": "ā¤
⤧ā¤ŋā¤ā¤¤ā¤Ž ā¤ŦāĨ-ā¤ĢāĨ⤰āĨā¤Ž",
"transcoding_max_b_frames_description": "ā¤ā¤āĨā¤ ā¤Žā¤žā¤¨ ⤏ā¤ā¤ĒāĨā¤Ąā¤ŧ⤍ ā¤Ļā¤āĨā¤ˇā¤¤ā¤ž ā¤ŽāĨ⤠⤏āĨā¤§ā¤žā¤° ā¤ā¤°ā¤¤āĨ ā¤šāĨā¤, ⤞āĨā¤ā¤ŋ⤍ ā¤ā¤¨āĨā¤āĨā¤Ąā¤ŋā¤ā¤ ā¤āĨ ⤧āĨā¤Žā¤ž ā¤ā¤° ā¤ĻāĨ⤤āĨ ā¤šāĨā¤āĨ¤",
"transcoding_max_bitrate": "ā¤
⤧ā¤ŋā¤ā¤¤ā¤Ž ā¤Ŧā¤ŋā¤ā¤°āĨā¤",
- "transcoding_max_bitrate_description": "ā¤
⤧ā¤ŋā¤ā¤¤ā¤Ž ā¤Ŧā¤ŋā¤ā¤°āĨ⤠⤏āĨ⤠ā¤ā¤°ā¤¨āĨ ⤏āĨ ā¤Ģā¤ŧā¤žā¤ā¤˛ ā¤ā¤ā¤žā¤° ā¤āĨ ā¤āĨ⤪ā¤ĩ⤤āĨā¤¤ā¤ž ā¤Ē⤰ ā¤Žā¤žā¤ŽāĨ⤞āĨ ā¤˛ā¤žā¤ā¤¤ ā¤āĨ ā¤¸ā¤žā¤Ĩ ā¤
⤧ā¤ŋ⤠ā¤ĒāĨ⤰āĨā¤ĩā¤žā¤¨āĨā¤Žā¤žā¤¨ā¤ŋ⤤ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž ā¤šāĨāĨ¤ 720p ā¤Ē⤰, ā¤¸ā¤žā¤Žā¤žā¤¨āĨ⤝ ā¤Žā¤žā¤¨ VP9 ā¤¯ā¤ž HEVC ā¤āĨ ⤞ā¤ŋ⤠2600k kbit/s ā¤¯ā¤ž H.264 ā¤āĨ ⤞ā¤ŋ⤠4500k kbit/s ā¤šāĨā¤āĨ¤ 0 ā¤Ē⤰ ⤏āĨā¤ ā¤šāĨ⤍āĨ ā¤Ē⤰ ā¤
ā¤āĨā¤ˇā¤ŽāĨ¤",
+ "transcoding_max_bitrate_description": "ā¤
⤧ā¤ŋā¤ā¤¤ā¤Ž ā¤Ŧā¤ŋā¤ā¤°āĨ⤠⤏āĨ⤠ā¤ā¤°ā¤¨āĨ ⤏āĨ ā¤Ģā¤ŧā¤žā¤ā¤˛ ā¤ā¤ā¤žā¤° ā¤āĨ ā¤āĨ⤪ā¤ĩ⤤āĨā¤¤ā¤ž ā¤Ē⤰ ā¤Žā¤žā¤ŽāĨ⤞āĨ ā¤˛ā¤žā¤ā¤¤ ā¤āĨ ā¤¸ā¤žā¤Ĩ ā¤
⤧ā¤ŋ⤠ā¤ĒāĨ⤰āĨā¤ĩā¤žā¤¨āĨā¤Žā¤žā¤¨ā¤ŋ⤤ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž ā¤šāĨāĨ¤ 720p ā¤Ē⤰, ā¤¸ā¤žā¤Žā¤žā¤¨āĨ⤝ ā¤Žā¤žā¤¨ VP9 ā¤¯ā¤ž HEVC ā¤āĨ ⤞ā¤ŋ⤠2600k kbit/s ā¤¯ā¤ž H.264 ā¤āĨ ⤞ā¤ŋ⤠4500k kbit/s ā¤šāĨā¤āĨ¤ 0 ā¤Ē⤰ ⤏āĨā¤ ā¤šāĨ⤍āĨ ā¤Ē⤰ ā¤
ā¤āĨā¤ˇā¤ŽāĨ¤ ā¤ā¤Ŧ ā¤āĨ⤠ā¤ā¤ā¤žā¤ ⤍ā¤ŋ⤰āĨā¤Ļā¤ŋ⤎āĨā¤ ā¤¨ā¤šāĨ⤠ā¤āĨ ā¤ā¤žā¤¤āĨ ā¤šāĨ, ⤤āĨ k (kbit/s ā¤āĨ ⤞ā¤ŋā¤) ā¤Žā¤žā¤¨ ⤞ā¤ŋā¤¯ā¤ž ā¤ā¤žā¤¤ā¤ž ā¤šāĨ; ā¤ā¤¸ā¤˛ā¤ŋ⤠5000, 5000k, ā¤ā¤° 5M (Mbit/s ā¤āĨ ⤞ā¤ŋā¤) ā¤¸ā¤Žā¤¤āĨ⤞āĨ⤝ ā¤šāĨā¤āĨ¤",
"transcoding_max_keyframe_interval": "ā¤
⤧ā¤ŋā¤ā¤¤ā¤Ž ā¤ŽāĨā¤āĨ⤝ā¤Ģā¤ŧāĨ⤰āĨā¤Ž ā¤
ā¤ā¤¤ā¤°ā¤žā¤˛",
"transcoding_max_keyframe_interval_description": "ā¤ŽāĨā¤āĨ⤝ā¤Ģā¤ŧāĨ⤰āĨā¤Ž ā¤āĨ ā¤ŦāĨ⤠ā¤
⤧ā¤ŋā¤ā¤¤ā¤Ž ā¤Ģā¤ŧāĨ⤰āĨā¤Ž ā¤ĻāĨ⤰āĨ ⤍ā¤ŋ⤰āĨā¤§ā¤žā¤°ā¤ŋ⤤ ā¤ā¤°ā¤¤ā¤ž ā¤šāĨāĨ¤",
"transcoding_optimal_description": "⤞ā¤āĨ⤎āĨ⤝ ⤰ā¤ŋā¤ā¤ŧāĨ⤞āĨ⤝āĨā¤ļ⤍ ⤏āĨ ā¤
⤧ā¤ŋ⤠ā¤ā¤ā¤āĨ ā¤ĩāĨā¤Ąā¤ŋ⤝āĨ ā¤¯ā¤ž ⤏āĨā¤ĩāĨā¤āĨ⤤ ā¤ĒāĨā¤°ā¤žā¤°āĨā¤Ē ā¤ŽāĨā¤ ā¤¨ā¤šāĨā¤",
@@ -359,6 +382,9 @@
"trash_number_of_days_description": "⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ⤝āĨ⤠ā¤āĨ ⤏āĨā¤Ĩā¤žā¤¯āĨ ⤰āĨā¤Ē ⤏āĨ ā¤šā¤ā¤žā¤¨āĨ ⤏āĨ ā¤Ēā¤šā¤˛āĨ ā¤ā¤¨āĨā¤šāĨ⤠ā¤āĨā¤Ąā¤ŧāĨā¤Ļā¤žā¤¨ ā¤ŽāĨ⤠⤰ā¤ā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠ā¤Ļā¤ŋ⤍āĨ⤠ā¤āĨ ⤏ā¤ā¤āĨā¤¯ā¤ž",
"trash_settings": "ā¤āĨ⤰āĨā¤ļ ⤏āĨā¤ā¤ŋā¤ā¤",
"trash_settings_description": "ā¤āĨ⤰āĨā¤ļ ⤏āĨā¤ā¤ŋā¤ā¤ ā¤ĒāĨ⤰ā¤Ŧā¤ā¤§ā¤ŋ⤤ ā¤ā¤°āĨā¤",
+ "unlink_all_oauth_accounts": "⤏ā¤āĨ ā¤.ā¤ā¤Ĩ ā¤ā¤žā¤¤āĨ⤠⤏āĨ ⤏ā¤ā¤Ē⤰āĨ⤠⤤āĨāĨ ā¤ĻāĨā¤",
+ "unlink_all_oauth_accounts_description": "⤍⤠ā¤ĒāĨ⤰ā¤Ļā¤žā¤¤ā¤ž ā¤Ē⤰ ā¤¸ā¤¤ā¤žā¤¨ā¤žā¤ā¤¤ā¤°ā¤Ŗ ā¤ā¤°ā¤¨āĨ ⤏āĨ ā¤Ēā¤šā¤˛āĨ ⤏ā¤āĨ ā¤.ā¤ā¤Ĩ ā¤ā¤žā¤¤āĨ⤠⤏āĨ ⤏ā¤ā¤Ē⤰āĨ⤠⤤āĨāĨā¤¨ā¤ž ā¤¯ā¤žā¤Ļ ⤰ā¤āĨā¤āĨ¤",
+ "unlink_all_oauth_accounts_prompt": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ā¤ĩā¤žā¤ā¤ ⤏ā¤āĨ ā¤.ā¤ā¤Ĩ ā¤ā¤žā¤¤āĨ⤠⤏āĨ ⤏ā¤ā¤Ē⤰āĨ⤠⤤āĨāĨā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤? ā¤ā¤¸ā¤¸āĨ ā¤ĒāĨ⤰⤤āĨ⤝āĨ⤠ā¤ā¤Ē⤝āĨā¤ā¤ā¤°āĨā¤¤ā¤ž ā¤āĨ ⤞ā¤ŋ⤠ā¤.ā¤ā¤Ĩ ā¤ā¤.ā¤ĄāĨ ⤰ā¤ĻāĨā¤Ļ ā¤šāĨ ā¤ā¤žā¤ā¤āĨ ā¤ā¤° ā¤ā¤¸āĨ ā¤ĒāĨ⤰āĨā¤ĩā¤ĩ⤤ ā¤¨ā¤šāĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤ž ⤏ā¤āĨā¤ā¤žāĨ¤",
"user_cleanup_job": "ā¤ā¤Ē⤝āĨā¤ā¤ā¤°āĨā¤¤ā¤ž ⤏ā¤Ģā¤ŧā¤žā¤",
"user_delete_delay": "{user} ā¤āĨ ā¤ā¤žā¤¤āĨ ā¤ā¤° ā¤Ē⤰ā¤ŋ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ⤝āĨ⤠ā¤āĨ {delay, plural, one {# day} other {# days}} ā¤ŽāĨ⤠⤏āĨā¤Ĩā¤žā¤¯āĨ ⤰āĨā¤Ē ⤏āĨ ā¤šā¤ā¤žā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠ā¤ļāĨā¤ĄāĨ⤝āĨ⤞ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤žā¤ā¤ā¤žāĨ¤",
"user_delete_delay_settings": "ā¤šā¤ā¤žā¤¨āĨ ā¤ŽāĨ⤠ā¤ĻāĨ⤰āĨ",
@@ -392,6 +418,8 @@
"advanced_settings_prefer_remote_title": "ā¤ĻāĨ⤰⤏āĨā¤Ĩ ā¤ā¤ĩā¤ŋ⤝āĨ⤠ā¤āĨ ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋā¤ā¤¤ā¤ž ā¤ĻāĨā¤",
"advanced_settings_proxy_headers_subtitle": "ā¤ĒāĨ⤰⤤āĨ⤝āĨ⤠⤍āĨā¤ā¤ĩ⤰āĨ⤠ā¤
⤍āĨ⤰āĨ⤧ ā¤āĨ ā¤¸ā¤žā¤Ĩ ā¤ā¤ŽāĨā¤Žā¤ŋ⤠ā¤ĻāĨā¤ĩā¤žā¤°ā¤ž ā¤āĨā¤āĨ ā¤ā¤žā¤¨āĨ ā¤ĩā¤žā¤˛āĨ ā¤ĒāĨ⤰āĨā¤āĨ⤏āĨ ā¤šāĨā¤Ąā¤° ā¤āĨ ā¤Ē⤰ā¤ŋā¤ā¤žā¤ˇā¤ŋ⤤ ā¤ā¤°āĨā¤",
"advanced_settings_proxy_headers_title": "ā¤ĒāĨ⤰āĨā¤āĨ⤏āĨ ā¤šāĨā¤Ąā¤°",
+ "advanced_settings_readonly_mode_subtitle": "⤰āĨā¤Ą-ā¤ā¤¨ā¤˛āĨ ā¤ĒāĨā¤°ā¤Ŗā¤žā¤˛āĨ ā¤āĨ ⤏ā¤āĨā¤ˇā¤Ž ā¤ā¤°ā¤¤ā¤ž ā¤šāĨ ā¤ā¤šā¤žā¤ ā¤ā¤ŋ⤤āĨ⤰ ā¤āĨ ā¤āĨā¤ĩ⤞ ā¤ĻāĨā¤ā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž ā¤šāĨ, ā¤ā¤ā¤žā¤§ā¤ŋ⤠ā¤ā¤ŋ⤤āĨ⤰āĨ⤠ā¤ā¤ž ā¤ā¤¯ā¤¨ ā¤ā¤°ā¤¨ā¤ž, ā¤¸ā¤žā¤ā¤ž ā¤ā¤°ā¤¨ā¤ž, ā¤ā¤žā¤¸āĨā¤ā¤ŋā¤ā¤ ā¤ā¤°ā¤¨ā¤ž, ā¤šā¤ā¤žā¤¨ā¤ž ā¤āĨ⤏āĨ ⤏ā¤āĨ ā¤āĨā¤ā¤ŧāĨ⤠ā¤
ā¤āĨā¤ˇā¤Ž ā¤šāĨā¤āĨ¤ ā¤ŽāĨā¤āĨ⤝ ⤏āĨā¤āĨ⤰āĨ⤍ ā¤ŽāĨ⤠ā¤ā¤Ē⤝āĨā¤ā¤ā¤°āĨā¤¤ā¤ž- ā¤
ā¤ĩā¤¤ā¤žā¤° ā¤āĨ ā¤Žā¤žā¤§āĨā¤¯ā¤Ž ⤏āĨ ⤰āĨā¤Ą-ā¤ā¤¨ā¤˛āĨ ā¤ĒāĨā¤°ā¤Ŗā¤žā¤˛āĨ ā¤āĨ ⤏ā¤āĨā¤ˇā¤Ž/ā¤
ā¤āĨā¤ˇā¤Ž ā¤ā¤°āĨā¤",
+ "advanced_settings_readonly_mode_title": "⤰āĨā¤Ą-ā¤ā¤¨ā¤˛āĨ ā¤ĒāĨā¤°ā¤Ŗā¤žā¤˛āĨ",
"advanced_settings_self_signed_ssl_subtitle": "⤏⤰āĨā¤ĩ⤰ ā¤ā¤ā¤Ąā¤ĒāĨā¤ā¤ā¤ ā¤āĨ ⤞ā¤ŋ⤠SSL ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨ⤰ ⤏⤤āĨā¤¯ā¤žā¤Ē⤍ ā¤āĨ ā¤āĨā¤Ąā¤ŧ ā¤ĻāĨā¤¤ā¤ž ā¤šāĨāĨ¤ ⤏āĨā¤ĩ-ā¤šā¤¸āĨā¤¤ā¤žā¤āĨ⤎⤰ā¤ŋ⤤ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨ⤰āĨ⤠ā¤āĨ ⤞ā¤ŋ⤠ā¤ā¤ĩā¤ļāĨā¤¯ā¤ ā¤šāĨāĨ¤",
"advanced_settings_self_signed_ssl_title": "⤏āĨā¤ĩ-ā¤šā¤¸āĨā¤¤ā¤žā¤āĨ⤎⤰ā¤ŋ⤤ SSL ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨ⤰āĨ⤠ā¤āĨ ā¤
⤍āĨā¤Žā¤¤ā¤ŋ ā¤ĻāĨā¤",
"advanced_settings_sync_remote_deletions_subtitle": "ā¤ĩāĨā¤Ŧ ā¤Ē⤰ ā¤ā¤žā¤°āĨ⤰ā¤ĩā¤žā¤ ā¤ā¤ŋ⤠ā¤ā¤žā¤¨āĨ ā¤Ē⤰ ā¤ā¤¸ ā¤Ąā¤ŋā¤ĩā¤žā¤ā¤¸ ā¤Ē⤰ ā¤ā¤ŋ⤏āĨ ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ ā¤āĨ ⤏āĨā¤ĩā¤ā¤žā¤˛ā¤ŋ⤤ ⤰āĨā¤Ē ⤏āĨ ā¤šā¤ā¤žā¤ā¤ ā¤¯ā¤ž ā¤ĒāĨ⤍⤰āĨ⤏āĨā¤Ĩā¤žā¤Ēā¤ŋ⤤ ā¤ā¤°āĨā¤",
@@ -419,6 +447,7 @@
"album_remove_user_confirmation": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ā¤ĩā¤žā¤ā¤ {user} ā¤āĨ ā¤šā¤ā¤žā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤?",
"album_search_not_found": "ā¤ā¤Ēā¤āĨ ā¤āĨ⤠⤏āĨ ā¤ŽāĨ⤞ ā¤ā¤žā¤¤ā¤ž ā¤āĨ⤠ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤¨ā¤šāĨā¤ ā¤Žā¤ŋā¤˛ā¤ž",
"album_share_no_users": "ā¤ā¤¸ā¤ž ⤞ā¤ā¤¤ā¤ž ā¤šāĨ ā¤ā¤ŋ ā¤ā¤Ē⤍āĨ ā¤¯ā¤š ā¤ā¤˛āĨā¤Ŧā¤Ž ⤏ā¤āĨ ā¤ā¤Ē⤝āĨā¤ā¤ā¤°āĨā¤¤ā¤žā¤ā¤ ā¤āĨ ā¤¸ā¤žā¤Ĩ ā¤¸ā¤žā¤ā¤ž ā¤ā¤° ā¤Ļā¤ŋā¤¯ā¤ž ā¤šāĨ ā¤¯ā¤ž ā¤ā¤Ēā¤āĨ ā¤Ēā¤žā¤¸ ā¤¸ā¤žā¤ā¤ž ā¤ā¤°ā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠ā¤āĨ⤠ā¤ā¤Ē⤝āĨā¤ā¤ā¤°āĨā¤¤ā¤ž ā¤¨ā¤šāĨā¤ ā¤šāĨāĨ¤",
+ "album_summary": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤¸ā¤žā¤°ā¤žā¤ā¤ļ",
"album_updated": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤
ā¤Ēā¤ĄāĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž",
"album_updated_setting_description": "ā¤ā¤Ŧ ā¤ā¤ŋ⤏āĨ ā¤¸ā¤žā¤ā¤ž ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ⤠⤍⤠⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋā¤¯ā¤žā¤ ā¤šāĨ⤠⤤āĨ ā¤ā¤ ā¤ā¤ŽāĨ⤞ ⤏āĨā¤ā¤¨ā¤ž ā¤ĒāĨā¤°ā¤žā¤ĒāĨ⤤ ā¤ā¤°āĨā¤",
"album_user_left": "ā¤Ŧā¤žā¤¯ā¤žā¤ {album}",
@@ -452,11 +481,16 @@
"api_key_description": "ā¤¯ā¤š ā¤āĨ ā¤āĨā¤ĩ⤞ ā¤ā¤ ā¤Ŧā¤žā¤° ā¤Ļā¤ŋā¤ā¤žā¤ ā¤ā¤žā¤ā¤āĨāĨ¤ ā¤ĩā¤ŋā¤ā¤ĄāĨ ā¤Ŧā¤ā¤Ļ ā¤ā¤°ā¤¨āĨ ⤏āĨ ā¤Ēā¤šā¤˛āĨ ā¤āĨā¤Ēā¤¯ā¤ž ā¤ā¤¸āĨ ā¤āĨā¤ĒāĨ ā¤ā¤°ā¤¨ā¤ž ⤏āĨ⤍ā¤ŋā¤ļāĨā¤ā¤ŋ⤤ ā¤ā¤°āĨā¤āĨ¤āĨ¤",
"api_key_empty": "ā¤ā¤Ēā¤ā¤ž ā¤ā¤ĒāĨā¤ā¤ ā¤āĨā¤ā¤āĨ ā¤¨ā¤žā¤Ž ā¤ā¤žā¤˛āĨ ā¤¨ā¤šāĨā¤ ā¤šāĨā¤¨ā¤ž ā¤ā¤žā¤šā¤ŋā¤",
"api_keys": "ā¤ā¤ĒāĨā¤ā¤ ā¤āĨā¤",
+ "app_architecture_variant": "⤰āĨā¤Ēā¤žā¤¨āĨ⤤⤰ (⤏āĨā¤Ĩā¤žā¤Ē⤤āĨ⤝/ā¤ā¤°āĨā¤ā¤ŋā¤āĨā¤āĨā¤ā¤°)",
"app_bar_signout_dialog_content": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ⤏āĨ⤍ā¤ŋā¤ļāĨā¤ā¤ŋ⤤ ā¤šāĨ⤠ā¤ā¤ŋ ā¤ā¤Ē ⤞āĨ⤠ā¤ā¤ā¤ ā¤ā¤°ā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤?",
"app_bar_signout_dialog_ok": "ā¤šā¤žā¤",
"app_bar_signout_dialog_title": "⤞āĨ⤠ā¤ā¤ā¤",
+ "app_download_links": "ā¤ā¤Ē ā¤Ąā¤žā¤ā¤¨ā¤˛āĨā¤Ą ⤞ā¤ŋā¤ā¤",
"app_settings": "ā¤ā¤ĒāĨ⤞ā¤ŋā¤āĨā¤ļ⤍ ⤏āĨā¤ā¤ŋā¤ā¤",
+ "app_stores": "ā¤ā¤Ē ⤏āĨā¤āĨ⤰/ā¤āĨā¤Ļā¤žā¤Ž",
+ "app_update_available": "ā¤ā¤§āĨ⤍ā¤ŋ⤠ā¤ā¤Ē ā¤ā¤Ē⤞ā¤ŦāĨ⤧ ā¤šāĨ",
"appears_in": "ā¤ĒāĨ⤰ā¤ā¤ ā¤šāĨā¤¤ā¤ž ā¤šāĨ",
+ "apply_count": "ā¤˛ā¤žā¤āĨ ā¤ā¤°āĨ⤠({count, number})",
"archive": "⤏ā¤ā¤āĨā¤°ā¤šā¤žā¤˛ā¤¯",
"archive_action_prompt": "{count} ā¤āĨ ⤏ā¤ā¤āĨā¤°ā¤š ā¤ŽāĨ⤠ā¤āĨā¤Ąā¤ŧā¤ž ā¤ā¤¯ā¤ž",
"archive_or_unarchive_photo": "ā¤Ģā¤ŧāĨā¤āĨ ā¤āĨ ⤏ā¤ā¤āĨā¤°ā¤šāĨ⤤ ā¤¯ā¤ž ā¤
⤏ā¤ā¤āĨā¤°ā¤šāĨ⤤ ā¤ā¤°āĨā¤",
@@ -465,17 +499,17 @@
"archive_size": "ā¤ĒāĨā¤°ā¤žā¤˛āĨ⤠ā¤ā¤ā¤žā¤°",
"archive_size_description": "ā¤Ąā¤žā¤ā¤¨ā¤˛āĨā¤Ą ā¤āĨ ⤞ā¤ŋ⤠⤏ā¤ā¤āĨā¤°ā¤š ā¤ā¤ā¤žā¤° ā¤āĨ⤍āĨā¤Ģā¤ŧā¤ŋā¤ā¤° ā¤ā¤°āĨ⤠(GiB ā¤ŽāĨā¤)",
"archived": "⤏ā¤ā¤āĨā¤°ā¤šā¤ŋ⤤",
- "archived_count": "{count, plural, other {# ⤏ā¤ā¤āĨā¤°ā¤šāĨ⤤ ā¤ā¤ŋ⤠ā¤ā¤}",
+ "archived_count": "{count, plural, other {# ⤏ā¤ā¤āĨā¤°ā¤šāĨ⤤ ā¤ā¤ŋ⤠ā¤ā¤}}",
"are_these_the_same_person": "ā¤āĨā¤¯ā¤ž ⤝āĨ ā¤ĩā¤šāĨ ā¤ĩāĨ⤝ā¤āĨ⤤ā¤ŋ ā¤šāĨā¤?",
"are_you_sure_to_do_this": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ā¤ĩā¤žā¤¸āĨ⤤ā¤ĩ ā¤ŽāĨ⤠ā¤ā¤¸āĨ ā¤ā¤°ā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤?",
"asset_action_delete_err_read_only": "ā¤āĨā¤ĩ⤞ ā¤Ēā¤ĸā¤ŧ⤍āĨ ⤝āĨā¤āĨ⤝ ā¤Ē⤰ā¤ŋ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ(ā¤ā¤) ā¤āĨ ā¤šā¤ā¤žā¤¯ā¤ž ā¤¨ā¤šāĨ⤠ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž, ā¤āĨā¤Ąā¤ŧā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž ā¤šāĨ",
"asset_action_share_err_offline": "ā¤ā¤Ģā¤ŧā¤˛ā¤žā¤ā¤¨ ā¤Ē⤰ā¤ŋ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ(ā¤ā¤) ā¤ĒāĨā¤°ā¤žā¤ĒāĨ⤤ ā¤¨ā¤šāĨ⤠ā¤āĨ ā¤ā¤ž ⤏ā¤ā¤¤āĨ, ā¤āĨā¤Ąā¤ŧāĨ ā¤ā¤ž ā¤°ā¤šāĨ ā¤šāĨ",
"asset_added_to_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛ā¤ž ā¤ā¤¯ā¤ž",
- "asset_adding_to_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛ā¤ž ā¤ā¤ž ā¤°ā¤šā¤ž ā¤šāĨ..āĨ¤",
+ "asset_adding_to_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛ā¤ž ā¤ā¤ž ā¤°ā¤šā¤ž ā¤šāĨâĻ",
"asset_description_updated": "⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ ā¤ĩā¤ŋā¤ĩ⤰⤪ ā¤
ā¤ĻāĨ⤝⤤⤍ ā¤ā¤° ā¤Ļā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž ā¤šāĨ",
"asset_filename_is_offline": "ā¤ā¤¸āĨ⤠{filename} ā¤ā¤Ģā¤ŧā¤˛ā¤žā¤ā¤¨ ā¤šāĨ",
"asset_has_unassigned_faces": "ā¤ā¤¸āĨā¤ ā¤ŽāĨ⤠ā¤
⤍ā¤ŋ⤰āĨā¤§ā¤žā¤°ā¤ŋ⤤ ā¤āĨā¤šā¤°āĨ ā¤šāĨā¤",
- "asset_hashing": "ā¤šāĨā¤ļā¤ŋā¤ā¤...āĨ¤",
+ "asset_hashing": "ā¤šāĨā¤ļā¤ŋā¤ā¤âĻ",
"asset_list_group_by_sub_title": "ā¤ĻāĨā¤ĩā¤žā¤°ā¤ž ā¤¸ā¤ŽāĨā¤š ā¤Ŧā¤¨ā¤žā¤ā¤",
"asset_list_layout_settings_dynamic_layout_title": "ā¤ā¤¤ā¤ŋā¤ļāĨ⤞ ⤞āĨā¤ā¤ā¤",
"asset_list_layout_settings_group_automatically": "⤏āĨā¤ĩā¤ā¤žā¤˛ā¤ŋ⤤",
@@ -489,6 +523,8 @@
"asset_restored_successfully": "⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ(ā¤¯ā¤žā¤) ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ⤰āĨā¤ĩ⤠ā¤ĒāĨ⤍⤰āĨ⤏āĨā¤Ĩā¤žā¤Ēā¤ŋ⤤ ā¤āĨ ā¤ā¤ā¤",
"asset_skipped": "ā¤āĨā¤Ąā¤ŧā¤ž ā¤ā¤¯ā¤ž",
"asset_skipped_in_trash": "ā¤ā¤ā¤°āĨ ā¤ŽāĨā¤",
+ "asset_trashed": "ā¤ā¤¸āĨ⤠⤍⤎āĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž",
+ "asset_troubleshoot": "ā¤ā¤¸āĨā¤ ā¤¸ā¤Žā¤¸āĨā¤¯ā¤ž ⤍ā¤ŋā¤ĩā¤žā¤°ā¤Ŗ",
"asset_uploaded": "ā¤
ā¤Ē⤞āĨā¤Ą ā¤ā¤ŋ⤠ā¤ā¤",
"asset_uploading": "ā¤
ā¤Ē⤞āĨā¤Ą ā¤šāĨ ā¤°ā¤šā¤ž ā¤šāĨâĻ",
"asset_viewer_settings_subtitle": "ā¤
ā¤Ē⤍āĨ ā¤āĨ⤞⤰āĨ ā¤ĩāĨ⤝āĨā¤
⤰ ⤏āĨā¤ā¤ŋā¤ā¤ ā¤ĒāĨ⤰ā¤Ŧā¤ā¤§ā¤ŋ⤤ ā¤ā¤°āĨā¤",
@@ -496,7 +532,9 @@
"assets": "⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋā¤¯ā¤žā¤",
"assets_added_count": "{count, plural, one {# asset} other {# assets}} ā¤āĨā¤Ąā¤ŧā¤ž ā¤ā¤¯ā¤ž",
"assets_added_to_album_count": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ⤠{count, plural, one {# asset} other {# assets}} ā¤āĨā¤Ąā¤ŧā¤ž ā¤ā¤¯ā¤ž",
+ "assets_added_to_albums_count": "{assetTotal, plural, one {# asset} other {# assets}} ā¤āĨ {albumTotal, plural, one {# album} other {# albums}} ⤏āĨ ā¤āĨāĨā¤ž ā¤ā¤¯ā¤ž",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} ā¤āĨ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨā¤ ā¤¨ā¤šāĨ⤠ā¤āĨā¤Ąā¤ŧā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž",
+ "assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} ā¤ā¤ŋ⤏āĨ ā¤ā¤˛āĨā¤Ŧā¤Ž ⤏āĨ ā¤¨ā¤šāĨ⤠ā¤āĨāĨāĨ ā¤ā¤ž ⤏ā¤ā¤¤āĨ",
"assets_count": "{count, plural, one {# ā¤ā¤ā¤ā¤Ž} other {# ā¤ā¤ā¤ā¤ŽāĨ⤏}}",
"assets_deleted_permanently": "{count} ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ(ā¤¯ā¤žā¤) ⤏āĨā¤Ĩā¤žā¤¯āĨ ⤰āĨā¤Ē ⤏āĨ ā¤šā¤ā¤ž ā¤ĻāĨ ā¤ā¤ā¤",
"assets_deleted_permanently_from_server": "{count} ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ(ā¤¯ā¤žā¤) ā¤ā¤Žā¤ŋ⤠⤏⤰āĨā¤ĩ⤰ ⤏āĨ ⤏āĨā¤Ĩā¤žā¤¯āĨ ⤰āĨā¤Ē ⤏āĨ ā¤šā¤ā¤ž ā¤ĻāĨ ā¤ā¤ā¤",
@@ -513,14 +551,17 @@
"assets_trashed_count": "ā¤āĨ⤰āĨā¤ļ ā¤āĨ ā¤ā¤ {count, plural, one {# asset} other {# assets}}",
"assets_trashed_from_server": "{count} ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ(ā¤¯ā¤žā¤) ā¤ā¤Žā¤ŋ⤠⤏⤰āĨā¤ĩ⤰ ⤏āĨ ā¤ā¤ā¤°āĨ ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛āĨ ā¤ā¤ā¤",
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}}ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ā¤ž ā¤Ēā¤šā¤˛āĨ ⤏āĨ ā¤šāĨ ā¤šā¤ŋ⤏āĨā¤¸ā¤ž ā¤ĨāĨ",
+ "assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} ā¤Ēā¤šā¤˛āĨ ā¤šāĨ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ⤠⤏ā¤ā¤¯āĨā¤ā¤ŋ⤤ ā¤šāĨā¤",
"authorized_devices": "ā¤
⤧ā¤ŋā¤āĨ⤤ ā¤ā¤Ēā¤ā¤°ā¤Ŗ",
"automatic_endpoint_switching_subtitle": "ā¤ā¤Ē⤞ā¤ŦāĨ⤧ ā¤šāĨ⤍āĨ ā¤Ē⤰ ⤍ā¤ŋ⤰āĨā¤Ļā¤ŋ⤎āĨ⤠ā¤ĩā¤žā¤-ā¤Ģā¤žā¤ ā¤¸āĨ ⤏āĨā¤Ĩā¤žā¤¨āĨ⤝ ⤰āĨā¤Ē ⤏āĨ ā¤ā¤¨āĨā¤āĨ⤠ā¤ā¤°āĨ⤠ā¤ā¤° ā¤
⤍āĨ⤝⤤āĨ⤰ ā¤ĩāĨā¤ā¤˛āĨā¤Ēā¤ŋ⤠ā¤ā¤¨āĨā¤āĨā¤ļ⤍ ā¤ā¤ž ā¤ā¤Ē⤝āĨ⤠ā¤ā¤°āĨā¤",
"automatic_endpoint_switching_title": "⤏āĨā¤ĩā¤ā¤žā¤˛ā¤ŋ⤤ URL ⤏āĨā¤ĩā¤ŋā¤ā¤ŋā¤ā¤",
"autoplay_slideshow": "ā¤ā¤āĨā¤ĒāĨ⤞āĨ ⤏āĨā¤˛ā¤žā¤ā¤Ą ā¤ļāĨ",
"back": "ā¤ĩā¤žā¤Ē⤏",
"back_close_deselect": "ā¤ĩā¤žā¤Ē⤏ ā¤ā¤žā¤ā¤, ā¤Ŧā¤ā¤Ļ ā¤ā¤°āĨā¤, ā¤¯ā¤ž ā¤
ā¤ā¤¯ā¤¨ā¤ŋ⤤ ā¤ā¤°āĨā¤",
+ "background_backup_running_error": "ā¤Ē⤰ā¤ŋā¤ĒāĨ⤰āĨā¤āĨ⤎āĨ⤝ ā¤ŦāĨā¤ā¤
ā¤Ē ā¤
ā¤āĨ ā¤ā¤žā¤°āĨ ā¤šāĨ, ⤍ā¤ŋā¤¯ā¤Žā¤žā¤ĩ⤞āĨ ā¤ŦāĨā¤ā¤
ā¤Ē ā¤ĒāĨā¤°ā¤žā¤°ā¤ā¤ ā¤¨ā¤šāĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž",
"background_location_permission": "ā¤ĒāĨ⤎āĨ⤠ā¤āĨā¤Žā¤ŋ ⤏āĨā¤Ĩā¤žā¤¨ ā¤
⤍āĨā¤Žā¤¤ā¤ŋ",
"background_location_permission_content": "ā¤ĒāĨ⤎āĨ⤠ā¤āĨā¤Žā¤ŋ ā¤ŽāĨ⤠ā¤ā¤˛ā¤¤āĨ ā¤¸ā¤Žā¤¯ ⤍āĨā¤ā¤ĩ⤰āĨ⤠ā¤Ŧā¤Ļ⤞⤍āĨ ā¤āĨ ⤞ā¤ŋā¤, Immich ā¤āĨ ā¤Ēā¤žā¤¸ *ā¤šā¤ŽāĨā¤ļā¤ž* ⤏ā¤āĨ⤠⤏āĨā¤Ĩā¤žā¤¨ ⤤⤠ā¤Ēā¤šāĨā¤ā¤ ā¤šāĨ⤍āĨ ā¤ā¤žā¤šā¤ŋā¤ ā¤¤ā¤žā¤ā¤ŋ ā¤ā¤Ē ā¤ĩā¤žā¤-ā¤Ģā¤žā¤ ā¤¨āĨā¤ā¤ĩ⤰āĨ⤠ā¤ā¤ž ā¤¨ā¤žā¤Ž ā¤Ēā¤ĸā¤ŧ ⤏ā¤āĨ",
+ "background_options": "ā¤Ē⤰ā¤ŋā¤ĒāĨ⤰āĨā¤āĨ⤎āĨ⤝ ā¤ĩā¤ŋā¤ā¤˛āĨā¤Ē",
"backup": "ā¤ŦāĨā¤ā¤
ā¤Ē",
"backup_album_selection_page_albums_device": "ā¤Ąā¤ŋā¤ĩā¤žā¤ā¤¸ ā¤Ē⤰ ā¤ā¤˛āĨā¤Ŧā¤Ž ({count})",
"backup_album_selection_page_albums_tap": "ā¤ļā¤žā¤Žā¤ŋ⤞ ā¤ā¤°ā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠ā¤āĨā¤Ē ā¤ā¤°āĨā¤, ā¤Ŧā¤žā¤šā¤° ā¤ā¤°ā¤¨āĨ ā¤āĨ ⤞ā¤ŋā¤ ā¤Ąā¤Ŧ⤞ ā¤āĨā¤Ē ā¤ā¤°āĨā¤",
@@ -528,8 +569,10 @@
"backup_album_selection_page_select_albums": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤āĨ⤍āĨā¤",
"backup_album_selection_page_selection_info": "ā¤ā¤¯ā¤¨ ā¤ā¤žā¤¨ā¤ā¤žā¤°āĨ",
"backup_album_selection_page_total_assets": "ā¤āĨ⤞ ā¤
ā¤ĻāĨā¤ĩā¤ŋ⤤āĨ⤝ ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋā¤¯ā¤žā¤",
+ "backup_albums_sync": "ā¤ŦāĨā¤ā¤
ā¤Ē ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ā¤ž ⤤āĨ⤞āĨ⤝ā¤ā¤žā¤˛ā¤¨",
"backup_all": "⤏ā¤āĨ",
"backup_background_service_backup_failed_message": "⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ⤝āĨ⤠ā¤ā¤ž ā¤ŦāĨā¤ā¤
ā¤Ē ⤞āĨ⤍āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞. ā¤ĒāĨ⤍⤠ā¤ĒāĨā¤°ā¤¯ā¤žā¤¸ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤ž ā¤°ā¤šā¤ž ā¤šāĨâĻ",
+ "backup_background_service_complete_notification": "ā¤ā¤¸āĨ⤠ā¤ā¤ž ā¤ŦāĨā¤ā¤
ā¤Ē ā¤ĒāĨā¤°ā¤ž ā¤šāĨā¤",
"backup_background_service_connection_failed_message": "⤏⤰āĨā¤ĩ⤰ ⤏āĨ ā¤ā¤¨āĨā¤āĨ⤠ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞. ā¤ĒāĨ⤍⤠ā¤ĒāĨā¤°ā¤¯ā¤žā¤¸ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤ž ā¤°ā¤šā¤ž ā¤šāĨâĻ",
"backup_background_service_current_upload_notification": "{filename} ā¤
ā¤Ē⤞āĨā¤Ą ā¤šāĨ ā¤°ā¤šā¤ž ā¤šāĨ",
"backup_background_service_default_notification": "⤍⤠ā¤Ē⤰ā¤ŋ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ⤝āĨ⤠ā¤āĨ ā¤ā¤žā¤ā¤ ā¤āĨ ā¤ā¤ž ā¤°ā¤šāĨ ā¤šāĨâĻ",
@@ -577,6 +620,7 @@
"backup_controller_page_turn_on": "ā¤
ā¤āĨ⤰ā¤āĨā¤Žā¤ŋ ā¤ŦāĨā¤ā¤
ā¤Ē ā¤ā¤žā¤˛āĨ ā¤ā¤°āĨā¤",
"backup_controller_page_uploading_file_info": "ā¤Ģā¤ŧā¤žā¤ā¤˛ ā¤ā¤žā¤¨ā¤ā¤žā¤°āĨ ā¤
ā¤Ē⤞āĨā¤Ą ā¤ā¤°ā¤¨ā¤ž",
"backup_err_only_album": "ā¤ā¤ā¤Žā¤žā¤¤āĨ⤰ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤¨ā¤šāĨā¤ ā¤šā¤ā¤žā¤¯ā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤ž",
+ "backup_error_sync_failed": "⤤āĨ⤞āĨ⤝ā¤ā¤žā¤˛ā¤¨ ā¤ĩā¤ŋā¤Ģ⤞. ā¤ŦāĨā¤ā¤
ā¤Ē ⤏ā¤ā¤¸ā¤žā¤§ā¤ŋ⤤ ā¤¨ā¤šāĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤ž ⤏ā¤ā¤¤ā¤žāĨ¤",
"backup_info_card_assets": "⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ",
"backup_manual_cancelled": "⤰ā¤ĻāĨā¤Ļ",
"backup_manual_in_progress": "ā¤
ā¤Ē⤞āĨā¤Ą ā¤Ēā¤šā¤˛āĨ ⤏āĨ ā¤šāĨ ā¤ĒāĨ⤰ā¤ā¤¤ā¤ŋ ā¤Ē⤰ ā¤šāĨāĨ¤ ā¤āĨ⤠ā¤ĻāĨ⤰ ā¤Ŧā¤žā¤Ļ ā¤ĒāĨā¤°ā¤¯ā¤žā¤¸ ā¤ā¤°āĨā¤",
@@ -638,12 +682,16 @@
"change_password_description": "ā¤¯ā¤š ā¤¯ā¤ž ⤤āĨ ā¤Ēā¤šā¤˛āĨ ā¤Ŧā¤žā¤° ā¤šāĨ ā¤ā¤Ŧ ā¤ā¤Ē ⤏ā¤ŋ⤏āĨā¤ā¤Ž ā¤ŽāĨā¤ ā¤¸ā¤žā¤ā¤¨ ā¤ā¤¨ ā¤ā¤° ā¤°ā¤šāĨ ā¤šāĨā¤ ā¤¯ā¤ž ā¤ā¤Ēā¤ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ŧā¤Ļ⤞⤍āĨ ā¤ā¤ž ā¤
⤍āĨ⤰āĨ⤧ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž ā¤šāĨāĨ¤",
"change_password_form_confirm_password": "ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤āĨ ā¤ĒāĨ⤎āĨā¤ā¤ŋ ā¤āĨā¤ā¤ŋ⤝āĨ",
"change_password_form_description": "ā¤¨ā¤Žā¤¸āĨ⤤āĨ {name},\n\nā¤¯ā¤ž ⤤āĨ ā¤ā¤Ē ā¤Ēā¤šā¤˛āĨ ā¤Ŧā¤žā¤° ⤏ā¤ŋ⤏āĨā¤ā¤Ž ā¤ŽāĨā¤ ā¤¸ā¤žā¤ā¤¨ ā¤ā¤¨ ā¤ā¤° ā¤°ā¤šāĨ ā¤šāĨā¤ ā¤¯ā¤ž ā¤Ģā¤ŋ⤰ ā¤ā¤Ēā¤ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ŧā¤Ļ⤞⤍āĨ ā¤ā¤ž ā¤
⤍āĨ⤰āĨ⤧ ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž ā¤šāĨāĨ¤ ā¤āĨā¤Ēā¤¯ā¤ž ⤍āĨā¤āĨ ā¤¨ā¤¯ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ąā¤žā¤˛āĨā¤āĨ¤",
+ "change_password_form_log_out": "ā¤
⤍āĨ⤝ ⤏ā¤āĨ ā¤Ąā¤ŋā¤ĩā¤žā¤ā¤¸ ā¤āĨ ⤞āĨ⤠ā¤ā¤ā¤ ā¤ā¤°āĨā¤",
+ "change_password_form_log_out_description": "ā¤
⤍āĨ⤝ ⤏ā¤āĨ ā¤Ąā¤ŋā¤ĩā¤žā¤ā¤¸ ⤏āĨ ⤞āĨ⤠ā¤ā¤ā¤ ā¤ā¤°ā¤¨ā¤ž ā¤
⤍āĨā¤ļā¤ā¤¸ā¤ŋ⤤ ā¤šāĨ",
"change_password_form_new_password": "ā¤¨ā¤¯ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą",
"change_password_form_password_mismatch": "ā¤¸ā¤žā¤ā¤āĨ⤤ā¤ŋ⤠ā¤ļā¤ŦāĨā¤Ļ ā¤ŽāĨ⤞ ā¤¨ā¤šāĨ⤠ā¤ā¤žā¤¤āĨ",
"change_password_form_reenter_new_password": "ā¤¨ā¤¯ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤ĒāĨ⤍⤠ā¤Ļ⤰āĨ⤠ā¤ā¤°āĨā¤",
"change_pin_code": "ā¤Ēā¤ŋ⤍ ā¤āĨā¤Ą ā¤Ŧā¤Ļ⤞āĨā¤",
"change_your_password": "ā¤
ā¤Ēā¤¨ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ŧā¤Ļ⤞āĨā¤",
"changed_visibility_successfully": "ā¤ĻāĨā¤ļāĨā¤¯ā¤¤ā¤ž ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ⤰āĨā¤ĩ⤠ā¤Ē⤰ā¤ŋā¤ĩ⤰āĨ⤤ā¤ŋ⤤",
+ "charging": "ā¤ā¤žā¤°āĨā¤ā¤ŋā¤ā¤",
+ "charging_requirement_mobile_backup": "ā¤Ē⤰ā¤ŋā¤ĒāĨ⤰āĨā¤āĨ⤎āĨ⤝ ā¤ŦāĨā¤ā¤
ā¤Ē ā¤āĨ ⤞ā¤ŋā¤ ā¤Ąā¤ŋā¤ĩā¤žā¤ā¤¸ ā¤ā¤ž ā¤ā¤žā¤°āĨā¤ā¤ŋā¤ā¤ ā¤ĒāĨ ⤞ā¤āĨ ā¤šāĨā¤¨ā¤ž ā¤ā¤ĩā¤ļāĨā¤¯ā¤ ā¤šāĨ",
"check_corrupt_asset_backup": "ā¤ĻāĨ⤎ā¤ŋ⤤ ā¤Ē⤰ā¤ŋ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ ā¤ŦāĨā¤ā¤
ā¤Ē ā¤āĨ ā¤ā¤žā¤ā¤ ā¤ā¤°āĨā¤",
"check_corrupt_asset_backup_button": "ā¤ā¤žā¤ā¤ ā¤ā¤°āĨā¤",
"check_corrupt_asset_backup_description": "ā¤¯ā¤š ā¤ā¤žā¤ā¤ ā¤āĨā¤ĩ⤞ ā¤ĩā¤žā¤-ā¤Ģā¤ŧā¤žā¤ ā¤Ē⤰ ā¤šāĨ ā¤ā¤°āĨ⤠ā¤ā¤° ⤏ā¤āĨ ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ⤝āĨ⤠ā¤ā¤ž ā¤ŦāĨā¤ā¤
ā¤Ē ⤞āĨ⤍āĨ ā¤āĨ ā¤Ŧā¤žā¤Ļ ā¤šāĨ ā¤ā¤°āĨā¤āĨ¤ ā¤ā¤¸ ā¤ĒāĨ⤰ā¤āĨ⤰ā¤ŋā¤¯ā¤ž ā¤ŽāĨ⤠ā¤āĨā¤ ā¤Žā¤ŋ⤍⤠⤞⤠⤏ā¤ā¤¤āĨ ā¤šāĨā¤āĨ¤",
@@ -665,7 +713,7 @@
"client_cert_subtitle": "ā¤āĨā¤ĩ⤞ PKCS12 (.p12, .pfx) ā¤Ģā¤ŧāĨ⤰āĨā¤ŽāĨ⤠ā¤ā¤ž ā¤¸ā¤Žā¤°āĨā¤Ĩ⤍ ā¤ā¤°ā¤¤ā¤ž ā¤šāĨāĨ¤ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨ⤰ ā¤ā¤¯ā¤žā¤¤/ā¤šā¤ā¤žā¤ā¤ ā¤āĨā¤ĩ⤞ ⤞āĨā¤ā¤ŋ⤍ ⤏āĨ ā¤Ēā¤šā¤˛āĨ ā¤ā¤Ē⤞ā¤ŦāĨ⤧ ā¤šāĨā¤",
"client_cert_title": "SSL ā¤āĨā¤˛ā¤žā¤ā¤ā¤ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨ⤰",
"clockwise": "ā¤Ļā¤āĨ⤎ā¤ŋā¤Ŗā¤žā¤ĩ⤰āĨ⤤",
- "close": "ā¤Ŧā¤ā¤Ļ",
+ "close": "ā¤Ŧā¤ā¤Ļ ā¤ā¤°āĨā¤",
"collapse": "ā¤ā¤ŋ⤰ ā¤ā¤žā¤¨ā¤ž",
"collapse_all": "⤏ā¤āĨ ā¤āĨ ⤏ā¤ā¤āĨā¤ā¤ŋ⤤ ā¤ā¤°āĨā¤",
"color": "⤰ā¤ā¤",
@@ -675,8 +723,8 @@
"comments_and_likes": "ā¤ā¤ŋā¤ĒāĨā¤Ē⤪ā¤ŋā¤¯ā¤žā¤ ā¤ā¤° ā¤Ē⤏ā¤ā¤Ļ",
"comments_are_disabled": "ā¤ā¤ŋā¤ĒāĨā¤Ē⤪ā¤ŋā¤¯ā¤žā¤ ā¤
ā¤āĨā¤ˇā¤Ž ā¤šāĨā¤",
"common_create_new_album": "ā¤¨ā¤¯ā¤ž ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤Ŧā¤¨ā¤žā¤ā¤",
- "completed": "ā¤ĒāĨā¤°ā¤ž ā¤šāĨā¤¨ā¤ž",
- "confirm": "ā¤ĒāĨ⤎āĨā¤ā¤ŋ",
+ "completed": "ā¤ĒāĨ⤰ā¤ŋ⤤",
+ "confirm": "ā¤ĒāĨ⤎āĨā¤ā¤ŋ ā¤ā¤°āĨā¤",
"confirm_admin_password": "ā¤ā¤Ąā¤Žā¤ŋ⤍ ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤āĨ ā¤ĒāĨ⤎āĨā¤ā¤ŋ ā¤ā¤°āĨā¤",
"confirm_delete_face": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ā¤ĩā¤žā¤ā¤ ā¤ā¤¸āĨ⤠⤏āĨ {name} ā¤āĨā¤šā¤°ā¤ž ā¤šā¤ā¤žā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤?",
"confirm_delete_shared_link": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ā¤ĩā¤žā¤ā¤ ā¤ā¤¸ ā¤¸ā¤žā¤ā¤ž ⤞ā¤ŋā¤ā¤ ā¤āĨ ā¤šā¤ā¤žā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤?",
@@ -685,13 +733,13 @@
"confirm_password": "ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤āĨ ā¤ĒāĨ⤎āĨā¤ā¤ŋ ā¤āĨā¤ā¤ŋ⤝āĨ",
"confirm_tag_face": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ā¤ā¤¸ ā¤āĨā¤šā¤°āĨ ā¤āĨ {name} ā¤āĨ ⤰āĨā¤Ē ā¤ŽāĨ⤠ā¤āĨ⤠ā¤ā¤°ā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤?",
"confirm_tag_face_unnamed": "ā¤āĨā¤¯ā¤ž ā¤ā¤Ē ā¤ā¤¸ ā¤āĨā¤šā¤°āĨ ā¤āĨ ā¤āĨ⤠ā¤ā¤°ā¤¨ā¤ž ā¤ā¤žā¤šā¤¤āĨ ā¤šāĨā¤?",
- "connected_device": "ā¤ā¤¨āĨā¤āĨā¤āĨā¤Ą ā¤Ąā¤ŋā¤ĩā¤žā¤ā¤¸",
+ "connected_device": "⤝āĨā¤ā¤ŋ⤤ ⤝ā¤ā¤¤āĨ⤰",
"connected_to": "⤏āĨ ā¤āĨā¤Ąā¤ŧā¤ž",
"contain": "ā¤¸ā¤Žā¤žā¤šā¤ŋ⤤",
"context": "⤏ā¤ā¤Ļ⤰āĨā¤",
"continue": "ā¤ā¤žā¤°āĨ",
"control_bottom_app_bar_create_new_album": "ā¤¨ā¤¯ā¤ž ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤Ŧā¤¨ā¤žā¤ā¤",
- "control_bottom_app_bar_delete_from_immich": "Immich ⤏āĨ ā¤šā¤ā¤žā¤ā¤",
+ "control_bottom_app_bar_delete_from_immich": "ā¤ā¤ŽāĨā¤Žā¤ŋ⤠⤏āĨ ā¤šā¤ā¤žā¤ā¤",
"control_bottom_app_bar_delete_from_local": "ā¤Ąā¤ŋā¤ĩā¤žā¤ā¤¸ ⤏āĨ ā¤šā¤ā¤žā¤ā¤",
"control_bottom_app_bar_edit_location": "⤏āĨā¤Ĩā¤žā¤¨ ⤏ā¤ā¤Ēā¤žā¤Ļā¤ŋ⤤ ā¤ā¤°āĨā¤",
"control_bottom_app_bar_edit_time": "ā¤¤ā¤žā¤°āĨ⤠ā¤ā¤° ā¤¸ā¤Žā¤¯ ⤏ā¤ā¤Ēā¤žā¤Ļā¤ŋ⤤ ā¤ā¤°āĨā¤",
@@ -713,6 +761,7 @@
"create": "⤤āĨā¤¯ā¤žā¤° ā¤ā¤°āĨā¤",
"create_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤Ŧā¤¨ā¤žā¤",
"create_album_page_untitled": "ā¤ļāĨ⤰āĨ⤎ā¤ā¤šāĨ⤍",
+ "create_api_key": "ā¤.ā¤ĒāĨ.ā¤ā¤. ā¤ā¤žā¤āĨ ā¤Ŧā¤¨ā¤žā¤ā¤",
"create_library": "ā¤˛ā¤žā¤ā¤ŦāĨ⤰āĨ⤰āĨ ā¤Ŧā¤¨ā¤žā¤ā¤",
"create_link": "⤞ā¤ŋā¤ā¤ ā¤Ŧā¤¨ā¤žā¤ā¤",
"create_link_to_share": "ā¤ļāĨ⤝⤰ ā¤ā¤°ā¤¨āĨ ā¤āĨ ⤞ā¤ŋ⤠⤞ā¤ŋā¤ā¤ ā¤Ŧā¤¨ā¤žā¤ā¤",
@@ -729,6 +778,7 @@
"create_user": "ā¤ā¤Ē⤝āĨā¤ā¤ā¤°āĨā¤¤ā¤ž ā¤Ŧā¤¨ā¤žā¤ā¤¯āĨ",
"created": "ā¤Ŧā¤¨ā¤žā¤¯ā¤ž",
"created_at": "ā¤Ŧā¤¨ā¤žā¤¯ā¤ž ā¤Ĩā¤ž",
+ "creating_linked_albums": "ā¤āĨāĨāĨ ā¤šāĨ⤠ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤Ŧā¤¨ā¤žā¤ ā¤ā¤ž ā¤°ā¤šāĨ ā¤šāĨā¤..āĨ¤",
"crop": "ā¤ā¤žā¤ā¤āĨā¤",
"curated_object_page_title": "ā¤āĨā¤ā¤ŧāĨā¤",
"current_device": "ā¤ĩ⤰āĨā¤¤ā¤Žā¤žā¤¨ ā¤ā¤Ēā¤ā¤°ā¤Ŗ",
@@ -741,6 +791,7 @@
"daily_title_text_date_year": "ā¤, ā¤ā¤Žā¤ā¤Žā¤ā¤Ž ā¤Ļā¤ŋ⤍, ā¤ĩ⤰āĨ⤎",
"dark": "ā¤Ąā¤žā¤°āĨā¤",
"dark_theme": "ā¤Ąā¤žā¤°āĨ⤠ā¤ĨāĨā¤Ž ā¤āĨā¤ā¤˛ ā¤ā¤°āĨā¤",
+ "date": "ā¤Ļā¤ŋā¤¨ā¤žā¤ā¤",
"date_after": "ā¤ā¤¸ā¤āĨ ā¤Ŧā¤žā¤Ļ ā¤āĨ ā¤¤ā¤žā¤°āĨā¤",
"date_and_time": "⤤ā¤ŋā¤Ĩā¤ŋ ā¤ā¤° ā¤¸ā¤Žā¤¯",
"date_before": "ā¤Ēā¤šā¤˛āĨ ā¤āĨ ā¤¤ā¤žā¤°āĨā¤",
@@ -748,6 +799,7 @@
"date_of_birth_saved": "ā¤ā¤¨āĨā¤Žā¤¤ā¤ŋā¤Ĩā¤ŋ ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ⤰āĨā¤ĩā¤ ā¤¸ā¤šāĨā¤āĨ ā¤ā¤",
"date_range": "⤤ā¤ŋā¤Ĩā¤ŋ ⤏āĨā¤Žā¤ž",
"day": "ā¤Ļā¤ŋ⤍",
+ "days": "ā¤Ļā¤ŋ⤍",
"deduplicate_all": "⤏ā¤āĨ ā¤āĨ ā¤ĄāĨā¤ĒāĨ⤞ā¤ŋā¤āĨ⤠ā¤ā¤°āĨā¤",
"deduplication_criteria_1": "ā¤ā¤ĩā¤ŋ ā¤ā¤ž ā¤ā¤ā¤žā¤° ā¤Ŧā¤žā¤ā¤āĨ⤏ ā¤ŽāĨā¤",
"deduplication_criteria_2": "EXIF ā¤ĄāĨā¤ā¤ž ā¤āĨ ⤏ā¤ā¤āĨā¤¯ā¤ž",
@@ -836,6 +888,8 @@
"edit_date": "⤏ā¤ā¤Ēā¤žā¤Ļ⤍ ā¤āĨ ā¤¤ā¤žā¤°āĨā¤",
"edit_date_and_time": "ā¤Ļā¤ŋā¤¨ā¤žā¤ā¤ ā¤ā¤° ā¤¸ā¤Žā¤¯ ⤏ā¤ā¤Ēā¤žā¤Ļā¤ŋ⤤ ā¤ā¤°āĨā¤",
"edit_date_and_time_action_prompt": "{count} ā¤¤ā¤žā¤°āĨ⤠ā¤ā¤° ā¤¸ā¤Žā¤¯ ⤏ā¤ā¤Ēā¤žā¤Ļā¤ŋ⤤ ā¤ā¤ŋ⤠ā¤ā¤",
+ "edit_date_and_time_by_offset": "ā¤
ā¤ā¤āĨ⤰ ⤏āĨ ā¤Ļā¤ŋā¤¨ā¤žā¤ā¤ ā¤Ŧā¤Ļ⤞āĨā¤",
+ "edit_date_and_time_by_offset_interval": "⤍⤝āĨ ā¤Ļā¤ŋā¤¨ā¤žā¤ā¤ ⤏āĨā¤Žā¤ž: {from} - {to}",
"edit_description": "⤏ā¤ā¤Ēā¤žā¤Ļā¤ŋ⤤ ā¤ā¤°āĨ⤠ā¤ĩ⤰āĨ⤪⤍",
"edit_description_prompt": "ā¤āĨā¤Ēā¤¯ā¤ž ā¤ā¤ ā¤¨ā¤¯ā¤ž ā¤ĩā¤ŋā¤ĩ⤰⤪ ā¤āĨ⤍āĨā¤:",
"edit_exclusion_pattern": "ā¤Ŧā¤šā¤ŋ⤎āĨā¤ā¤°ā¤Ŗ ā¤ĒāĨā¤ā¤°āĨ⤍ ⤏ā¤ā¤Ēā¤žā¤Ļā¤ŋ⤤ ā¤ā¤°āĨā¤",
@@ -874,7 +928,9 @@
"error": "ā¤ā¤˛ā¤¤āĨ",
"error_change_sort_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ā¤ž ā¤āĨā¤°ā¤Ž ā¤Ŧā¤Ļ⤞⤍āĨ ā¤ŽāĨ⤠ā¤
⤏ā¤Ģ⤞ ā¤°ā¤šā¤ž",
"error_delete_face": "ā¤ā¤¸āĨ⤠⤏āĨ ā¤āĨā¤šā¤°āĨ ā¤āĨ ā¤šā¤ā¤žā¤¨āĨ ā¤ŽāĨ⤠⤤āĨ⤰āĨā¤ā¤ŋ ā¤šāĨā¤",
+ "error_getting_places": "⤏āĨā¤Ĩā¤žā¤¨āĨ⤠ā¤āĨ ā¤ĒāĨā¤°ā¤žā¤ĒāĨ⤤ ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠⤤āĨ⤰āĨā¤ā¤ŋ ā¤šāĨā¤",
"error_loading_image": "ā¤ā¤ĩā¤ŋ ⤞āĨā¤Ą ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠⤤āĨ⤰āĨā¤ā¤ŋ",
+ "error_loading_partners": "ā¤āĨāĨāĨā¤Ļā¤žā¤° ⤞āĨā¤Ą ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠⤤āĨ⤰āĨā¤ā¤ŋ ā¤šāĨā¤: {error}",
"error_saving_image": "⤤āĨ⤰āĨā¤ā¤ŋ: {error}",
"error_tag_face_bounding_box": "ā¤āĨā¤šā¤°āĨ ā¤āĨ ā¤āĨ⤠ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠⤤āĨ⤰āĨā¤ā¤ŋ â ā¤Ŧā¤žā¤ā¤ā¤Ąā¤ŋā¤ā¤ ā¤ŦāĨā¤āĨ⤏ ⤍ā¤ŋ⤰āĨā¤ĻāĨā¤ļā¤žā¤ā¤ ā¤ĒāĨā¤°ā¤žā¤ĒāĨ⤤ ā¤¨ā¤šāĨ⤠ā¤ā¤° ⤏ā¤āĨ",
"error_title": "⤤āĨ⤰āĨā¤ā¤ŋ - ā¤āĨ⤠ā¤ā¤˛ā¤¤ ā¤šāĨ ā¤ā¤¯ā¤ž",
@@ -907,6 +963,7 @@
"failed_to_load_notifications": "⤏āĨā¤ā¤¨ā¤žā¤ā¤ ⤞āĨā¤Ą ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
"failed_to_load_people": "⤞āĨā¤āĨ⤠ā¤āĨ ⤞āĨā¤Ą ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
"failed_to_remove_product_key": "ā¤ā¤¤āĨā¤Ēā¤žā¤Ļ ā¤āĨā¤ā¤āĨ ⤍ā¤ŋā¤ā¤žā¤˛ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
+ "failed_to_reset_pin_code": "ā¤Ēā¤ŋ⤍ ā¤āĨā¤Ą ⤰āĨ⤏āĨ⤠ā¤ā¤°ā¤¨ā¤ž ā¤ĩā¤ŋā¤Ģ⤞ ā¤šāĨā¤",
"failed_to_stack_assets": "ā¤Ē⤰ā¤ŋ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ⤝āĨ⤠ā¤ā¤ž ā¤ĸāĨ⤰ ⤞ā¤ā¤žā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
"failed_to_unstack_assets": "ā¤Ē⤰ā¤ŋ⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ⤝āĨ⤠ā¤ā¤ž ā¤ĸāĨ⤰ ā¤āĨ⤞⤍āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
"failed_to_update_notification_status": "⤏āĨā¤ā¤¨ā¤ž ā¤āĨ ⤏āĨā¤Ĩā¤ŋ⤤ā¤ŋ ā¤
ā¤Ēā¤ĄāĨ⤠ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
@@ -915,6 +972,7 @@
"paths_validation_failed": "{paths, plural, one {# ā¤Ēā¤Ĩ} other {# ā¤Ēā¤Ĩ}} ⤏⤤āĨā¤¯ā¤žā¤Ē⤍ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞ ā¤°ā¤šāĨ",
"profile_picture_transparent_pixels": "ā¤ĒāĨ⤰āĨā¤Ģā¤ŧā¤žā¤ā¤˛ ā¤ā¤ŋ⤤āĨ⤰āĨā¤ ā¤ŽāĨ⤠ā¤Ēā¤žā¤°ā¤Ļ⤰āĨā¤ļāĨ ā¤Ēā¤ŋā¤āĨ⤏āĨ⤞ ā¤¨ā¤šāĨā¤ ā¤šāĨ ⤏ā¤ā¤¤āĨāĨ¤",
"quota_higher_than_disk_size": "ā¤ā¤Ē⤍āĨ ā¤Ąā¤ŋ⤏āĨ⤠ā¤ā¤ā¤žā¤° ⤏āĨ ā¤
⤧ā¤ŋ⤠ā¤āĨā¤ā¤ž ⤍ā¤ŋ⤰āĨā¤§ā¤žā¤°ā¤ŋ⤤ ā¤ā¤ŋā¤¯ā¤ž ā¤šāĨ",
+ "something_went_wrong": "ā¤āĨ⤠⤤āĨ⤰āĨā¤ā¤ŋ ā¤šāĨā¤",
"unable_to_add_album_users": "ā¤ā¤Ē⤝āĨā¤ā¤ā¤°āĨā¤¤ā¤žā¤ā¤ ā¤āĨ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨā¤ ā¤Ąā¤žā¤˛ā¤¨āĨ ā¤ŽāĨ⤠ā¤
ā¤¸ā¤Žā¤°āĨā¤Ĩ",
"unable_to_add_assets_to_shared_link": "ā¤¸ā¤žā¤ā¤ž ⤞ā¤ŋā¤ā¤ ā¤ŽāĨ⤠⤏ā¤ā¤Ē⤤āĨ⤤ā¤ŋ ā¤Ąā¤žā¤˛ā¤¨āĨ ā¤ŽāĨ⤠ā¤
ā¤¸ā¤Žā¤°āĨā¤Ĩ",
"unable_to_add_comment": "ā¤ā¤ŋā¤ĒāĨā¤Ē⤪āĨ ā¤Ąā¤žā¤˛ā¤¨āĨ ā¤ŽāĨ⤠ā¤
ā¤¸ā¤Žā¤°āĨā¤Ĩ",
@@ -1000,22 +1058,42 @@
},
"exif": "ā¤ā¤āĨ⤏ā¤ŋā¤Ģ",
"exif_bottom_sheet_description": "ā¤ĩā¤ŋā¤ĩ⤰⤪ ā¤āĨā¤Ąā¤ŧāĨā¤..āĨ¤",
+ "exif_bottom_sheet_description_error": "ā¤ĩā¤ŋā¤ĩ⤰⤪ ā¤āĨ ā¤ā¤§āĨ⤍āĨā¤ā¤°ā¤Ŗ ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠⤤āĨ⤰āĨā¤ā¤ŋ ā¤šāĨā¤",
+ "exif_bottom_sheet_details": "ā¤ĩā¤ŋā¤ĩ⤰⤪",
+ "exif_bottom_sheet_location": "⤏āĨā¤Ĩā¤žā¤¨",
+ "exif_bottom_sheet_no_description": "ā¤āĨ⤠ā¤ĩā¤ŋā¤ĩ⤰⤪ ā¤¨ā¤šāĨā¤",
+ "exif_bottom_sheet_people": "⤞āĨā¤",
"exif_bottom_sheet_person_add_person": "ā¤¨ā¤žā¤Ž ā¤Ąā¤žā¤˛āĨā¤",
"exit_slideshow": "⤏āĨā¤˛ā¤žā¤ā¤Ą ā¤ļāĨ ⤏āĨ ā¤Ŧā¤žā¤šā¤° ⤍ā¤ŋā¤ā¤˛āĨā¤",
"expand_all": "⤏ā¤āĨ ā¤ā¤ž ā¤ĩā¤ŋ⤏āĨā¤¤ā¤žā¤°",
+ "experimental_settings_new_asset_list_subtitle": "ā¤ā¤žā¤°āĨ⤝ ā¤ĒāĨ⤰ā¤ā¤¤ā¤ŋ ā¤Ē⤰ ā¤šāĨ",
+ "experimental_settings_new_asset_list_title": "ā¤ĒāĨ⤰⤝āĨā¤ā¤žā¤¤āĨā¤Žā¤ ā¤ĢāĨā¤āĨ ā¤āĨ⤰ā¤ŋā¤Ą ⤏ā¤āĨā¤ˇā¤Ž ā¤ā¤°āĨā¤",
+ "experimental_settings_subtitle": "ā¤
ā¤Ē⤍āĨ ā¤āĨā¤ā¤ŋā¤Ž ā¤Ē⤰ ā¤ā¤Ē⤝āĨ⤠ā¤ā¤°āĨā¤!",
+ "experimental_settings_title": "ā¤ĒāĨ⤰⤝āĨā¤ā¤žā¤¤āĨā¤Žā¤",
"expire_after": "ā¤ā¤āĨ⤏ā¤Ēā¤žā¤¯ā¤° ā¤ā¤ĢāĨā¤ā¤°",
"expired": "ā¤ā¤¤āĨā¤Ž ā¤šāĨ ā¤āĨā¤ā¤ž",
+ "expires_date": "{date} ā¤āĨ ā¤¸ā¤Žā¤žā¤ĒāĨ⤤ ā¤šāĨ ā¤°ā¤šā¤ž ā¤šāĨ",
"explore": "ā¤
⤍āĨā¤ĩāĨ⤎⤪ ā¤ā¤°ā¤¨ā¤ž",
+ "explorer": "ā¤¸ā¤Žā¤¨āĨā¤ĩāĨ⤎ā¤",
"export": "⤍ā¤ŋ⤰āĨā¤¯ā¤žā¤¤",
"export_as_json": "JSON ā¤āĨ ⤰āĨā¤Ē ā¤ŽāĨ⤠⤍ā¤ŋ⤰āĨā¤¯ā¤žā¤¤ ā¤ā¤°āĨā¤",
+ "export_database": "ā¤ĄāĨā¤ā¤žā¤ŦāĨ⤏ ⤍ā¤ŋ⤰āĨā¤¯ā¤žā¤¤ ā¤ā¤°āĨā¤",
+ "export_database_description": "ā¤ā¤¸.ā¤āĨ⤝āĨ.ā¤˛ā¤žā¤ā¤ ā¤ĄāĨā¤ā¤žā¤ŦāĨ⤏ ⤍ā¤ŋ⤰āĨā¤¯ā¤žā¤¤ ā¤ā¤°āĨā¤",
"extension": "ā¤ĩā¤ŋ⤏āĨā¤¤ā¤žā¤°",
"external": "ā¤Ŧā¤žā¤šā¤°āĨ",
"external_libraries": "ā¤Ŧā¤žā¤šā¤°āĨ ā¤ĒāĨ⤏āĨ⤤ā¤ā¤žā¤˛ā¤¯",
+ "external_network": "ā¤Ŧā¤žā¤šā¤°āĨ ⤍āĨā¤ā¤ĩ⤰āĨā¤",
"external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"face_unassigned": "⤏āĨā¤ā¤ĒāĨ ā¤¨ā¤šāĨ⤠ā¤ā¤",
+ "failed": "ā¤ĩā¤ŋā¤Ģ⤞ ā¤šāĨā¤",
+ "failed_to_authenticate": "ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤ŋ⤤ ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
+ "failed_to_load_assets": "ā¤ā¤¸āĨ⤠⤞āĨā¤Ą ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
+ "failed_to_load_folder": "ā¤ĢāĨ⤞āĨā¤Ąā¤° ⤞āĨā¤Ą ā¤ā¤°ā¤¨āĨ ā¤ŽāĨ⤠ā¤ĩā¤ŋā¤Ģ⤞",
"favorite": "ā¤Ē⤏ā¤ā¤ĻāĨā¤Ļā¤ž",
+ "favorite_action_prompt": "{count} ā¤Ē⤏ā¤ā¤ĻāĨā¤Ļā¤ž ⤏ā¤ā¤ā¤˛ā¤¨ ā¤ŽāĨ⤠ā¤āĨāĨāĨ ā¤ā¤",
"favorite_or_unfavorite_photo": "ā¤Ē⤏ā¤ā¤ĻāĨā¤Ļā¤ž ā¤¯ā¤ž ā¤¨ā¤žā¤Ē⤏ā¤ā¤Ļ ā¤ĢāĨā¤āĨ",
"favorites": "ā¤Ē⤏ā¤ā¤ĻāĨā¤Ļā¤ž",
+ "favorites_page_no_favorites": "ā¤āĨ⤠ā¤Ē⤏ā¤ā¤ĻāĨā¤Ļā¤ž ā¤ā¤¸āĨā¤ ā¤¨ā¤šāĨā¤ ā¤Žā¤ŋ⤞āĨ",
"feature_photo_updated": "ā¤Ģā¤ŧāĨā¤ā¤° ā¤Ģā¤ŧāĨā¤āĨ ā¤
ā¤Ēā¤ĄāĨ⤠ā¤ā¤ŋā¤¯ā¤ž ā¤ā¤¯ā¤ž",
"file_name": "ā¤Ģā¤ŧā¤žā¤ā¤˛ ā¤ā¤ž ā¤¨ā¤žā¤Ž",
"file_name_or_extension": "ā¤Ģā¤ŧā¤žā¤ā¤˛ ā¤ā¤ž ā¤¨ā¤žā¤Ž ā¤¯ā¤ž ā¤ā¤āĨ⤏ā¤āĨā¤ā¤ļ⤍",
diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json
index 281c1c266f..071dd27a16 100644
--- a/i18n/nb_NO.json
+++ b/i18n/nb_NO.json
@@ -1716,6 +1716,7 @@
"running": "Kjører",
"save": "Lagre",
"save_to_gallery": "Lagre til galleriet",
+ "saved": "Lagret",
"saved_api_key": "Lagret API-nøkkel",
"saved_profile": "Lagret profil",
"saved_settings": "Lagret instillinger",
@@ -1732,7 +1733,7 @@
"search_by_description_example": "Turdag i Sapa",
"search_by_filename": "Søk etter filnavn og filtype",
"search_by_filename_example": "f.eks. IMG_1234.JPG eller PNG",
- "search_by_ocr": "Søk med OCR",
+ "search_by_ocr": "Søk etter tekst i bilde",
"search_by_ocr_example": "Latte",
"search_camera_lens_model": "Søk etter objektivmodell...",
"search_camera_make": "Søk etter kameramerke...",
diff --git a/i18n/pl.json b/i18n/pl.json
index 0302eeafae..8bbefef148 100644
--- a/i18n/pl.json
+++ b/i18n/pl.json
@@ -1716,6 +1716,7 @@
"running": "W trakcie",
"save": "Zapisz",
"save_to_gallery": "Zapisz w galerii",
+ "saved": "Zapisano",
"saved_api_key": "Zapisany klucz API",
"saved_profile": "Zapisany profil",
"saved_settings": "Zapisane ustawienia",
diff --git a/i18n/pt.json b/i18n/pt.json
index e3bbb97965..4e04331ad3 100644
--- a/i18n/pt.json
+++ b/i18n/pt.json
@@ -344,7 +344,7 @@
"transcoding_hardware_acceleration": "AceleraÃ§ÃŖo de hardware",
"transcoding_hardware_acceleration_description": "Experimental; transcodificaÃ§ÃŖo mais rÃĄpida, mas poderÃĄ ter qualidade inferior com a mesma taxa de bits",
"transcoding_hardware_decoding": "DecodificaÃ§ÃŖo de hardware",
- "transcoding_hardware_decoding_setting_description": "Permite a aceleraÃ§ÃŖo ponta a ponta em vez de apenas acelerar a codificaÃ§ÃŖo. Pode nÃŖo funcionar em todos os formatos de arquivo.",
+ "transcoding_hardware_decoding_setting_description": "Permite a aceleraÃ§ÃŖo ponta a ponta em vez de apenas acelerar a codificaÃ§ÃŖo. Pode nÃŖo funcionar em todos os videos.",
"transcoding_max_b_frames": "MÃĄximo de quadros B",
"transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiÃĒncia da compressÃŖo, mas tornam a codificaÃ§ÃŖo mais lenta. Pode nÃŖo ser compatÃvel com aceleraÃ§ÃŖo de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.",
"transcoding_max_bitrate": "Taxa de bits mÃĄxima",
@@ -455,7 +455,7 @@
"album_viewer_appbar_delete_confirm": "Tem certeza que deseja excluir este ÃĄlbum da sua conta?",
"album_viewer_appbar_share_err_delete": "Ocorreu um erro ao eliminar ÃĄlbum",
"album_viewer_appbar_share_err_leave": "Ocorreu um erro ao sair do ÃĄlbum",
- "album_viewer_appbar_share_err_remove": "Houveram problemas ao remover arquivos do ÃĄlbum",
+ "album_viewer_appbar_share_err_remove": "Ocorreu um erro ao remover ficheiros do ÃĄlbum",
"album_viewer_appbar_share_err_title": "Ocorreu um erro ao alterar o tÃtulo do ÃĄlbum",
"album_viewer_appbar_share_leave": "Deixar ÃĄlbum",
"album_viewer_appbar_share_to": "Compartilhar com",
@@ -494,7 +494,7 @@
"archive": "Arquivo",
"archive_action_prompt": "{count} adicionados ao Arquivo",
"archive_or_unarchive_photo": "Arquivar ou desarquivar foto",
- "archive_page_no_archived_assets": "Nenhum arquivo encontrado",
+ "archive_page_no_archived_assets": "Nenhum ficheiro arquivado encontrado",
"archive_page_title": "Arquivo ({count})",
"archive_size": "Tamanho do arquivo",
"archive_size_description": "Configure o tamanho do arquivo para transferÃĒncias (em GiB)",
@@ -502,8 +502,8 @@
"archived_count": "{count, plural, one {#Arquivado # item} other {Arquivados # itens}}",
"are_these_the_same_person": "Estas pessoas sÃŖo a mesma pessoa?",
"are_you_sure_to_do_this": "Tem a certeza de que quer fazer isto?",
- "asset_action_delete_err_read_only": "NÃŖo Ê possÃvel excluir arquivo sÃŗ leitura, ignorando",
- "asset_action_share_err_offline": "NÃŖo foi possÃvel obter os arquivos offline, ignorando",
+ "asset_action_delete_err_read_only": "NÃŖo Ê possÃvel eliminar ficheiro sÃŗ de leitura, a ignorar",
+ "asset_action_share_err_offline": "NÃŖo foi possÃvel obter os ficheiros offline, a ignorar",
"asset_added_to_album": "Adicionado ao ÃĄlbum",
"asset_adding_to_album": "A adicionar ao ÃĄlbumâĻ",
"asset_description_updated": "A descriÃ§ÃŖo do ficheiro foi atualizada",
@@ -513,14 +513,14 @@
"asset_list_group_by_sub_title": "Agrupar por",
"asset_list_layout_settings_dynamic_layout_title": "Layout dinÃĸmico",
"asset_list_layout_settings_group_automatically": "AutomÃĄtico",
- "asset_list_layout_settings_group_by": "Agrupar arquivos por",
+ "asset_list_layout_settings_group_by": "Agrupar ficheiros por",
"asset_list_layout_settings_group_by_month_day": "MÃĒs + dia",
"asset_list_layout_sub_title": "DisposiÃ§ÃŖo",
"asset_list_settings_subtitle": "ConfiguraçÃĩes de disposiÃ§ÃŖo da grade de fotos",
"asset_list_settings_title": "Grade de fotos",
"asset_offline": "Ficheiro IndisponÃvel",
"asset_offline_description": "Este ficheiro externo deixou de estar disponÃvel no disco. Contacte o seu administrador do Immich para obter ajuda.",
- "asset_restored_successfully": "Arquivo restaurado com sucesso",
+ "asset_restored_successfully": "FIcheiro restaurado com sucesso",
"asset_skipped": "Ignorado",
"asset_skipped_in_trash": "Na reciclagem",
"asset_trashed": "Ficheiro apagado",
@@ -565,19 +565,19 @@
"backup": "CÃŗpia de segurança",
"backup_album_selection_page_albums_device": "Ãlbuns no dispositivo ({count})",
"backup_album_selection_page_albums_tap": "Toque para incluir, duplo toque para excluir",
- "backup_album_selection_page_assets_scatter": "Os arquivos podem estar espalhados em vÃĄrios ÃĄlbuns. Assim, os ÃĄlbuns podem ser incluÃdos ou excluÃdos durante o processo de backup.",
+ "backup_album_selection_page_assets_scatter": "Os ficheros podem estar espalhados por vÃĄrios ÃĄlbuns. Desta forma, os ÃĄlbuns podem ser incluÃdos ou excluÃdos durante o processo de cÃŗpia de segurança.",
"backup_album_selection_page_select_albums": "Selecione Ãlbuns",
"backup_album_selection_page_selection_info": "InformaçÃĩes da SeleÃ§ÃŖo",
- "backup_album_selection_page_total_assets": "Total de arquivos Ãēnicos",
+ "backup_album_selection_page_total_assets": "Total de ficheiros Ãēnicos",
"backup_albums_sync": "CÃŗpia de segurança de sincronizaÃ§ÃŖo de ÃĄlbuns",
"backup_all": "Tudo",
"backup_background_service_backup_failed_message": "Ocorreu um erro ao efetuar cÃŗpia de segurança dos ficheiros. A tentar de novoâĻ",
"backup_background_service_complete_notification": "CÃŗpia de conteÃēdos concluÃda",
"backup_background_service_connection_failed_message": "Ocorreu um erro na ligaÃ§ÃŖo ao servidor. A tentar de novoâĻ",
"backup_background_service_current_upload_notification": "A enviar {filename}",
- "backup_background_service_default_notification": "Verificando novos arquivosâĻ",
+ "backup_background_service_default_notification": "A verificar se hÃĄ novos ficheirosâĻ",
"backup_background_service_error_title": "Erro de backup",
- "backup_background_service_in_progress_notification": "Fazendo backup dos arquivosâĻ",
+ "backup_background_service_in_progress_notification": "A fazer cÃŗpia de segurança dos seus ficheirosâĻ",
"backup_background_service_upload_failure_notification": "Ocorreu um erro ao enviar {filename}",
"backup_controller_page_albums": "Backup Ãlbuns",
"backup_controller_page_background_app_refresh_disabled_content": "Para utilizar a cÃŗpia de segurança em segundo plano, ative a atualizaÃ§ÃŖo da aplicaÃ§ÃŖo em segundo plano em DefiniçÃĩes > Geral > AtualizaÃ§ÃŖo da aplicaÃ§ÃŖo em segundo plano.",
@@ -600,7 +600,7 @@
"backup_controller_page_backup_selected": "Selecionado: ",
"backup_controller_page_backup_sub": "Fotos e vÃdeos salvos em backup",
"backup_controller_page_created": "Criado em: {date}",
- "backup_controller_page_desc_backup": "Ative o backup para enviar automÃĄticamente novos arquivos para o servidor.",
+ "backup_controller_page_desc_backup": "Ative a cÃŗpia de segurança em primeiro plano para enviar novos ficheiros automaticamente ao abrir a aplicaÃ§ÃŖo.",
"backup_controller_page_excluded": "Eliminado: ",
"backup_controller_page_failed": "Falhou ({count})",
"backup_controller_page_filename": "Nome do ficheiro: {filename} [{size}]",
@@ -618,10 +618,10 @@
"backup_controller_page_total_sub": "Todas as fotos e vÃdeos dos ÃĄlbuns selecionados",
"backup_controller_page_turn_off": "Desativar backup",
"backup_controller_page_turn_on": "Ativar backup",
- "backup_controller_page_uploading_file_info": "Enviando arquivo",
+ "backup_controller_page_uploading_file_info": "A enviar informaçÃĩes do ficheiro",
"backup_err_only_album": "NÃŖo Ê possÃvel remover apenas o ÃĄlbum",
"backup_error_sync_failed": "A sincronizaÃ§ÃŖo falhou. NÃŖo Ê possÃvel fazer a cÃŗpia de segurança.",
- "backup_info_card_assets": "arquivos",
+ "backup_info_card_assets": "ficheiros",
"backup_manual_cancelled": "Cancelado",
"backup_manual_in_progress": "Envio jÃĄ estÃĄ em progresso. Tente novamente mais tarde",
"backup_manual_success": "Sucesso",
@@ -694,7 +694,7 @@
"charging_requirement_mobile_backup": "CÃŗpia de segurança de fundo necessita que o dispositivo esteja a carregar",
"check_corrupt_asset_backup": "Verificar por backups corrompidos",
"check_corrupt_asset_backup_button": "Verificar",
- "check_corrupt_asset_backup_description": "Execute esta verificaÃ§ÃŖo somente em uma rede Wi-Fi e quando o backup de todos os arquivos jÃĄ estiver concluÃdo. O processo demora alguns minutos.",
+ "check_corrupt_asset_backup_description": "Execute esta verificaÃ§ÃŖo apenas numa rede Wi-Fi e quando a cÃŗpia de segurança de todos os ficheiros jÃĄ estiver concluÃda. O processo pode demorar alguns minutos.",
"check_logs": "Verificar registos",
"choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir",
"city": "Cidade/Localidade",
@@ -770,7 +770,7 @@
"create_new_person": "Criar nova pessoa",
"create_new_person_hint": "Associe os ficheiros a uma nova pessoa",
"create_new_user": "Criar novo utilizador",
- "create_shared_album_page_share_add_assets": "ADICIONAR ARQUIVOS",
+ "create_shared_album_page_share_add_assets": "ADICIONAR FICHEIROS",
"create_shared_album_page_share_select_photos": "Selecionar Fotos",
"create_shared_link": "Criar link partilhado",
"create_tag": "Criar etiqueta",
@@ -812,10 +812,10 @@
"delete_action_prompt": "{count} eliminados",
"delete_album": "Apagar ÃĄlbum",
"delete_api_key_prompt": "Tem a certeza de que deseja remover esta chave de API?",
- "delete_dialog_alert": "Esses arquivos serÃŖo permanentemente apagados do Immich e de seu dispositivo",
- "delete_dialog_alert_local": "Estes arquivos serÃŖo permanentemente excluÃdos do seu dispositivo, mas continuarÃŖo disponÃveis no servidor Immich",
- "delete_dialog_alert_local_non_backed_up": "NÃŖo hÃĄ backup de alguns dos arquivos no servidor e eles serÃŖo excluÃdos permanentemente do seu dispositivo",
- "delete_dialog_alert_remote": "Estes arquivos serÃŖo permanentemente excluÃdos do servidor Immich",
+ "delete_dialog_alert": "Estes ficheiros serÃŖo eliminados permanentemente do Immich e do seu dispositivo",
+ "delete_dialog_alert_local": "Estes ficheiros serÃŖo eliminados permanentemente do seu dispositivo, mas continuarÃŖo disponÃveis no servidor Immich",
+ "delete_dialog_alert_local_non_backed_up": "Alguns dos ficheiros nÃŖo tÃĒm cÃŗpia de segurança no Immich e serÃŖo eliminados permanentemente do seu dispositivo",
+ "delete_dialog_alert_remote": "Estes ficheiros serÃŖo eliminados permanentemente do servidor Immich",
"delete_dialog_ok_force": "Confirmo que quero excluir",
"delete_dialog_title": "Excluir Permanentemente",
"delete_duplicates_confirmation": "Tem a certeza de que deseja eliminar permanentemente estes itens duplicados?",
@@ -824,7 +824,7 @@
"delete_library": "Eliminar Biblioteca",
"delete_link": "Eliminar link",
"delete_local_action_prompt": "{count} eliminados localmente",
- "delete_local_dialog_ok_backed_up_only": "Excluir apenas arquivos com backup",
+ "delete_local_dialog_ok_backed_up_only": "Eliminar apenas ficheiros com cÃŗpia de segurança",
"delete_local_dialog_ok_force": "Excluir mesmo assim",
"delete_others": "Excluir outros",
"delete_permanently": "Eliminar permanentemente",
@@ -872,7 +872,7 @@
"download_settings_description": "Gerir definiçÃĩes relacionadas com a transferÃĒncia de ficheiros",
"download_started": "Iniciando",
"download_sucess": "Baixado com sucesso",
- "download_sucess_android": "O arquivo foi baixado na pasta DCIM/Immich",
+ "download_sucess_android": "O ficheiro foi descarregado para a pasta DCIM/Immich",
"download_waiting_to_retry": "Tentando novamente",
"downloading": "A transferir",
"downloading_asset_filename": "A transferir o ficheiro {filename}",
@@ -1134,7 +1134,7 @@
"group_owner": "Agrupar por dono",
"group_places_by": "Agrupar lugares por...",
"group_year": "Agrupar por ano",
- "haptic_feedback_switch": "Habilitar vibraÃ§ÃŖo",
+ "haptic_feedback_switch": "Ativar vibraÃ§ÃŖo",
"haptic_feedback_title": "VibraÃ§ÃŖo",
"has_quota": "Tem quota",
"hash_asset": "Criptografar ficheiro",
@@ -1154,20 +1154,20 @@
"hide_unnamed_people": "Ocultar pessoas sem nome",
"home_page_add_to_album_conflicts": "Foram adicionados {added} ficheiros ao ÃĄlbum {album}. {failed} ficheiros jÃĄ estÃŖo no ÃĄlbum.",
"home_page_add_to_album_err_local": "Ainda nÃŖo Ê possÃvel adicionar recursos locais aos ÃĄlbuns, ignorando",
- "home_page_add_to_album_success": "Adicionado {added} arquivos ao ÃĄlbum {album}.",
- "home_page_album_err_partner": "Ainda nÃŖo Ê possÃvel adicionar arquivos do parceiro a um ÃĄlbum, ignorando",
+ "home_page_add_to_album_success": "{added} ficheiros foram adicionados ao ÃĄlbum {album}.",
+ "home_page_album_err_partner": "Ainda nÃŖo Ê possÃvel adicionar ficheiros do parceiro a um ÃĄlbum, a ignorar",
"home_page_archive_err_local": "Ainda nÃŖo Ê possÃvel arquivar recursos locais, ignorando",
"home_page_archive_err_partner": "NÃŖo Ê possÃvel arquivar Fotos e Videos do parceiro, ignorando",
"home_page_building_timeline": "Construindo a linha do tempo",
- "home_page_delete_err_partner": "NÃŖo Ê possÃvel excluir arquivos do parceiro, ignorando",
- "home_page_delete_remote_err_local": "Foram selecionados arquivos locais para excluir remotamente, ignorando",
+ "home_page_delete_err_partner": "NÃŖo Ê possÃvel eliminar ficheiros do parceiro, a ignorar",
+ "home_page_delete_remote_err_local": "Foram selecionados ficheiros locais para excluir remotamente, a ignorar",
"home_page_favorite_err_local": "Ainda nÃŖo Ê possÃvel adicionar recursos locais favoritos, ignorando",
- "home_page_favorite_err_partner": "Ainda nÃŖo Ê possÃvel marcar arquivos do parceiro como favoritos, ignorando",
+ "home_page_favorite_err_partner": "Ainda nÃŖo Ê possÃvel marcar ficheiros do parceiro como favoritos, a ignorar",
"home_page_first_time_notice": "Se Ê a primeira vez que utiliza a aplicaÃ§ÃŖo, certifique-se de que marca pelo menos um ÃĄlbum do dispositivo para cÃŗpia de segurança, para a linha do tempo poder ser preenchida com fotos e vÃdeos",
"home_page_locked_error_local": "NÃŖo foi possÃvel mover ficheiros locais para a pasta trancada, a continuar",
"home_page_locked_error_partner": "NÃŖo foi possÃvel mover ficheiros do parceiro para a pasta trancada, a continuar",
- "home_page_share_err_local": "NÃŖo Ê possÃvel compartilhar arquivos locais com um link, ignorando",
- "home_page_upload_err_limit": "SÃŗ Ê possÃvel enviar 30 arquivos por vez, ignorando",
+ "home_page_share_err_local": "NÃŖo Ê possÃvel partilhar ficheiros locais com um link, a ignorar",
+ "home_page_upload_err_limit": "SÃŗ Ê possÃvel enviar um mÃĄximo de 30 ficheiros de cada vez, a ignorar",
"host": "Servidor",
"hour": "Hora",
"hours": "Horas",
@@ -1187,7 +1187,7 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {VÃdeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {VÃdeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}",
"image_saved_successfully": "Imagem salva",
- "image_viewer_page_state_provider_download_started": "Baixando arquivo",
+ "image_viewer_page_state_provider_download_started": "A descarregar ficheiro",
"image_viewer_page_state_provider_download_success": "Baixado com sucesso",
"image_viewer_page_state_provider_share_error": "Erro ao compartilhar",
"immich_logo": "Logotipo do Immich",
@@ -1244,7 +1244,7 @@
"library_options": "OpçÃĩes da biblioteca",
"library_page_device_albums": "Ãlbuns no dispositivo",
"library_page_new_album": "Novo ÃĄlbum",
- "library_page_sort_asset_count": "Quantidade de arquivos",
+ "library_page_sort_asset_count": "Quantidade de ficheiros",
"library_page_sort_created": "Data de criaÃ§ÃŖo",
"library_page_sort_last_modified": "Ãltima modificaÃ§ÃŖo",
"library_page_sort_title": "TÃtulo do ÃĄlbum",
@@ -1383,8 +1383,8 @@
"moved_to_archive": "{count, plural, one {Foi movido # ficheiro} other {Foram movidos # ficheiros}} para o arquivo",
"moved_to_library": "{count, plural, one {Foi movido # ficheiro} other {Foram movidos # ficheiros}} para a biblioteca",
"moved_to_trash": "Enviado para a reciclagem",
- "multiselect_grid_edit_date_time_err_read_only": "NÃŖo Ê possÃvel editar a data de arquivo sÃŗ leitura, ignorando",
- "multiselect_grid_edit_gps_err_read_only": "NÃŖo Ê possÃvel editar a localizaÃ§ÃŖo de arquivo sÃŗ leitura, ignorando",
+ "multiselect_grid_edit_date_time_err_read_only": "NÃŖo Ê possÃvel editar a data de um ficheiro sÃŗ de leitura, a ignorar",
+ "multiselect_grid_edit_gps_err_read_only": "NÃŖo Ê possÃvel editar a localizaÃ§ÃŖo de um ficheiro sÃŗ de leitura, a ignorar",
"mute_memories": "Silenciar MemÃŗrias",
"my_albums": "Os meus ÃĄlbuns",
"name": "Nome",
@@ -1417,7 +1417,7 @@
"no_albums_yet": "Parece que ainda nÃŖo tem nenhum ÃĄlbum.",
"no_archived_assets_message": "Arquive fotos e vÃdeos para os ocultar da sua visualizaÃ§ÃŖo de fotos",
"no_assets_message": "FAÃA CLIQUE PARA CARREGAR A SUA PRIMEIRA FOTO",
- "no_assets_to_show": "NÃŖo hÃĄ arquivos para exibir",
+ "no_assets_to_show": "NÃŖo hÃĄ ficheiros para exibir",
"no_cast_devices_found": "Nenhum dispositivo de transmissÃŖo encontrado",
"no_checksum_local": "Sem cÃĄlculo de verificaÃ§ÃŖo disponÃvel - nÃŖo pode capturar conteÃēdos locais",
"no_checksum_remote": "Soma de verificaÃ§ÃŖo (checksum) nÃŖo disponÃvel - nÃŖo Ê possÃvel obter o recurso remoto",
@@ -1586,7 +1586,7 @@
"public_album": "Ãlbum pÃēblico",
"public_share": "Partilhar Publicamente",
"purchase_account_info": "Apoiante",
- "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de cÃŗdigo aberto",
+ "purchase_activated_subtitle": "Agradecemos o seu apoio ao Immich e ao software de cÃŗdigo aberto",
"purchase_activated_time": "Ativado em {date}",
"purchase_activated_title": "A sua chave foi ativada com sucesso",
"purchase_button_activate": "Ativar",
@@ -1747,7 +1747,7 @@
"search_filter_date_title": "Selecione a data",
"search_filter_display_option_not_in_album": "Fora de ÃĄlbum",
"search_filter_display_options": "OpçÃĩes de exibiÃ§ÃŖo",
- "search_filter_filename": "Pesquisar por nome do arquivo",
+ "search_filter_filename": "Pesquisar por nome do ficheiro",
"search_filter_location": "LocalizaÃ§ÃŖo",
"search_filter_location_title": "Selecione a localizaÃ§ÃŖo",
"search_filter_media_type": "Tipo da mÃdia",
@@ -1839,15 +1839,15 @@
"setting_notifications_notify_minutes": "{count} minutos",
"setting_notifications_notify_never": "Nunca",
"setting_notifications_notify_seconds": "{count} segundos",
- "setting_notifications_single_progress_subtitle": "InformaçÃĩes detalhadas sobre o progresso do envio por arquivo",
+ "setting_notifications_single_progress_subtitle": "InformaçÃĩes detalhadas sobre o progresso do envio por ficheiro",
"setting_notifications_single_progress_title": "Mostrar progresso detalhado do backup em segundo plano",
"setting_notifications_subtitle": "Ajuste as preferÃĒncias de notificaÃ§ÃŖo",
- "setting_notifications_total_progress_subtitle": "Progresso do envio de arquivos (concluÃdos/total)",
+ "setting_notifications_total_progress_subtitle": "Progresso do envio de ficheiro (concluÃdos/total)",
"setting_notifications_total_progress_title": "Mostrar progresso total do backup em segundo plano",
"setting_video_viewer_auto_play_subtitle": "Reproduzir os vÃdeos automaticamente quando abertos",
"setting_video_viewer_auto_play_title": "Reproduzir vÃdeos automaticamente",
"setting_video_viewer_looping_title": "Repetir",
- "setting_video_viewer_original_video_subtitle": "Ao transmitir um vÃdeo do servidor, usar o arquivo original, mesmo quando uma versÃŖo transcodificada esteja disponÃvel. Pode fazer com que o vÃdeo demore para carregar. VÃdeos disponÃveis localmente sÃŖo exibidos na qualidade original independente desta configuraÃ§ÃŖo.",
+ "setting_video_viewer_original_video_subtitle": "Ao transmitir um vÃdeo do servidor, usar o ficheiro original, mesmo se uma versÃŖo transcodificada estiver disponÃvel. Pode causar interrupçÃĩes. VÃdeos disponÃveis localmente sÃŖo exibidos na qualidade original independentemente desta definiÃ§ÃŖo.",
"setting_video_viewer_original_video_title": "Forçar vÃdeo original",
"settings": "DefiniçÃĩes",
"settings_require_restart": "Reinicie o Immich para aplicar essa configuraÃ§ÃŖo",
@@ -2019,7 +2019,7 @@
"theme_setting_primary_color_subtitle": "Selecione a cor primÃĄria, utilizada nas açÃĩes principais e nos realces.",
"theme_setting_primary_color_title": "Cor primÃĄria",
"theme_setting_system_primary_color_title": "Use a cor do sistema",
- "theme_setting_system_theme_switch": "AutomÃĄtico (Siga a configuraÃ§ÃŖo do sistema)",
+ "theme_setting_system_theme_switch": "AutomÃĄtico (Seguir a configuraÃ§ÃŖo do sistema)",
"theme_setting_theme_subtitle": "Escolha a configuraÃ§ÃŖo do tema da aplicaÃ§ÃŖo",
"theme_setting_three_stage_loading_subtitle": "O carregamento em trÃĒs estÃĄgios pode aumentar o desempenho do carregamento, mas causa uma carga de rede significativamente maior",
"theme_setting_three_stage_loading_title": "Habilitar carregamento em trÃĒs estÃĄgios",
@@ -2048,11 +2048,11 @@
"trash_emptied": "Lixeira esvaziada",
"trash_no_results_message": "Fotos e vÃdeos enviados para a reciclagem aparecem aqui.",
"trash_page_delete_all": "Excluir tudo",
- "trash_page_empty_trash_dialog_content": "Deseja esvaziar a lixera? Estes arquivos serÃŖo apagados de forma permanente do Immich",
+ "trash_page_empty_trash_dialog_content": "Deseja esvaziar a reciclagem? Estes ficheiros serÃŖo apagados permanentemente do Immich",
"trash_page_info": "Ficheiros na reciclagem irÃŖo ser eliminados permanentemente apÃŗs {days} dias",
"trash_page_no_assets": "Lixeira vazia",
"trash_page_restore_all": "Restaurar tudo",
- "trash_page_select_assets_btn": "Selecionar arquivos",
+ "trash_page_select_assets_btn": "Selecionar ficheiros",
"trash_page_title": "Reciclagem ({count})",
"trashed_items_will_be_permanently_deleted_after": "Os itens da reciclagem sÃŖo eliminados permanentemente apÃŗs {days, plural, one {# dia} other {# dias}}.",
"troubleshoot": "Diagnosticar problemas",
@@ -2094,8 +2094,8 @@
"upload_action_prompt": "{count} Ã espera de carregar",
"upload_concurrency": "Carregamentos em simultÃĸneo",
"upload_details": "Detalhes do Carregamento",
- "upload_dialog_info": "Deseja fazer o backup dos arquivos selecionados no servidor?",
- "upload_dialog_title": "Enviar arquivo",
+ "upload_dialog_info": "Deseja realizar uma cÃŗpia de segurança dos ficheiros selecionados para o servidor?",
+ "upload_dialog_title": "Enviar ficheiro",
"upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a pÃĄgina para ver os novos ficheiros enviados.",
"upload_finished": "Carregamento acabado",
"upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}",
diff --git a/i18n/ru.json b/i18n/ru.json
index d7c50be699..52cd3adbc3 100644
--- a/i18n/ru.json
+++ b/i18n/ru.json
@@ -485,7 +485,7 @@
"app_bar_signout_dialog_content": "ĐŅ ŅвĐĩŅĐĩĐŊŅ, ŅŅĐž Ņ
ĐžŅиŅĐĩ вŅĐšŅи?",
"app_bar_signout_dialog_ok": "Đа",
"app_bar_signout_dialog_title": "ĐŅĐšŅи",
- "app_download_links": "ĐĐ°ĐŗŅŅСĐēа ĐŋŅиĐģĐžĐļĐĩĐŊиŅ",
+ "app_download_links": "ĐĄŅŅĐģĐēи ĐŊа ĐˇĐ°ĐŗŅŅСĐēŅ ĐŧОйиĐģŅĐŊĐžĐŗĐž ĐŋŅиĐģĐžĐļĐĩĐŊиŅ",
"app_settings": "ĐаŅаĐŧĐĩŅŅŅ ĐŋŅиĐģĐžĐļĐĩĐŊиŅ",
"app_stores": "ĐĐ°ĐŗĐ°ĐˇĐ¸ĐŊŅ ĐŋŅиĐģĐžĐļĐĩĐŊиК",
"app_update_available": "ĐĐžŅŅŅĐŋĐŊа ĐŊĐžĐ˛Đ°Ņ Đ˛ĐĩŅŅĐ¸Ņ ĐŋŅиĐģĐžĐļĐĩĐŊиŅ",
@@ -1346,7 +1346,7 @@
"map_zoom_to_see_photos": "ĐŖĐŧĐĩĐŊŅŅĐĩĐŊиĐĩ ĐŧаŅŅŅайа Đ´ĐģŅ ĐŋŅĐžŅĐŧĐžŅŅа ŅĐžŅĐžĐŗŅаŅиК",
"mark_all_as_read": "ĐŅĐžŅиŅаĐŊĐž",
"mark_as_read": "ĐŅĐŧĐĩŅиŅŅ ĐēаĐē ĐŋŅĐžŅиŅаĐŊĐŊĐžĐĩ",
- "marked_all_as_read": "ĐŅĐŧĐĩŅĐĩĐŊŅ ĐēаĐē ĐŋŅĐžŅиŅаĐŊĐŊŅĐĩ",
+ "marked_all_as_read": "ĐŅĐĩ ŅвĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ ĐžŅĐŧĐĩŅĐĩĐŊŅ ĐēаĐē ĐŋŅĐžŅиŅаĐŊĐŊŅĐĩ",
"matches": "ХОвĐŋадĐĩĐŊиŅ",
"matching_assets": "ХООŅвĐĩŅŅŅвŅŅŅиĐĩ ОйŅĐĩĐēŅŅ",
"media_type": "ĐĸиĐŋ ĐŧĐĩдиа",
@@ -1453,7 +1453,7 @@
"oauth": "OAuth",
"obtainium_configurator": "ĐаŅŅŅОКĐēа Obtainium",
"obtainium_configurator_instructions": "ĐĐģŅ ŅŅŅаĐŊОвĐēи и ОйĐŊОвĐģĐĩĐŊĐ¸Ņ Android ĐŋŅиĐģĐžĐļĐĩĐŊĐ¸Ņ Immich ĐŊаĐŋŅŅĐŧŅŅ Đ¸Đˇ иŅŅĐžŅĐŊиĐēОв ĐŊа GitHub (ĐŧиĐŊŅŅ ĐŧĐ°ĐŗĐ°ĐˇĐ¸ĐŊŅ ĐŋŅиĐģĐžĐļĐĩĐŊиК) ĐŧĐžĐļĐŊĐž иŅĐŋĐžĐģŅСОваŅŅ Obtainium. ХОСдаКŅĐĩ ĐŊОвŅĐš API ĐēĐģŅŅ Đ¸ ŅĐēаĐļиŅĐĩ аŅŅ
иŅĐĩĐēŅŅŅŅ ĐŋŅиĐģĐžĐļĐĩĐŊĐ¸Ņ Đ´ĐģŅ ŅĐžŅĐŧиŅОваĐŊĐ¸Ņ ŅŅŅĐģĐēи Đ´ĐģŅ Obtainium.",
- "ocr": "РаŅĐŋОСĐŊаваĐŊиĐĩ ŅĐĩĐēŅŅа",
+ "ocr": "OCR",
"official_immich_resources": "ĐŅиŅиаĐģŅĐŊŅĐĩ ŅĐĩŅŅŅŅŅ Immich",
"offline": "ĐĐĩĐ´ĐžŅŅŅĐŋĐĩĐŊ",
"offset": "ĐĄĐŧĐĩŅĐĩĐŊиĐĩ",
@@ -1858,7 +1858,7 @@
"share_add_photos": "ĐОйавиŅŅ ŅĐžŅĐž",
"share_assets_selected": "{count} вŅĐąŅаĐŊĐž",
"share_dialog_preparing": "ĐĐžĐ´ĐŗĐžŅОвĐēа...",
- "share_link": "ĐОдĐĩĐģиŅŅŅŅ ŅŅŅĐģĐēОК",
+ "share_link": "ХОСдаŅŅ ŅŅŅĐģĐēŅ",
"shared": "ĐĐąŅиe",
"shared_album_activities_input_disable": "ĐĐžĐŧĐŧĐĩĐŊŅаŅии ĐžŅĐēĐģŅŅĐĩĐŊŅ",
"shared_album_activity_remove_content": "ĐŖĐ´Đ°ĐģиŅŅ ŅООйŅĐĩĐŊиĐĩ?",
diff --git a/i18n/sv.json b/i18n/sv.json
index db7854a38a..d8f45465af 100644
--- a/i18n/sv.json
+++ b/i18n/sv.json
@@ -418,7 +418,7 @@
"advanced_settings_prefer_remote_title": "FÃļredra bilder frÃĨn servern",
"advanced_settings_proxy_headers_subtitle": "Definiera proxy-headers som Immich ska skicka med i varje närverksanrop",
"advanced_settings_proxy_headers_title": "Anpassade proxyheaders [EXPERIMENTELLT]",
- "advanced_settings_readonly_mode_subtitle": "Aktiverar skrivskyddat läge där foton endast kan visas. FÃļljande funktioner inaktiveras: välj flera bilder, dela, casta, ta bort bilder. Aktivera/inaktivera skrivskyddat läge via profilbilden pÃĨ appens hemskärm",
+ "advanced_settings_readonly_mode_subtitle": "Aktiverar skrivskyddat-läge där foton endast kan visas. FÃļljande funktioner inaktiveras: välj flera bilder, dela, casta, ta bort bilder. Aktivera/inaktivera skrivskyddat läge via profilbilden pÃĨ appens hemskärm",
"advanced_settings_readonly_mode_title": "Skrivskyddat läge",
"advanced_settings_self_signed_ssl_subtitle": "Hoppar Ãļver verifiering av serverns SSL-certifikat. Krävs fÃļr självsignerade certifikat.",
"advanced_settings_self_signed_ssl_title": "TillÃĨt självsignerade SSL-certifikat [EXPERIMENTELLT]",
@@ -791,6 +791,7 @@
"daily_title_text_date_year": "E, dd MMM, yyyy",
"dark": "MÃļrk",
"dark_theme": "Växla mÃļrkt tema",
+ "date": "Datum",
"date_after": "Datum efter",
"date_and_time": "Datum och Tid",
"date_before": "Datum fÃļre",
@@ -1099,6 +1100,7 @@
"features_setting_description": "Hantera appens funktioner",
"file_name": "Filnamn",
"file_name_or_extension": "Filnamn eller -tillägg",
+ "file_size": "Filstorlek",
"filename": "Filnamn",
"filetype": "Filtyp",
"filter": "Filter",
@@ -1262,6 +1264,7 @@
"local_media_summary": "Sammanfattning av lokala medier",
"local_network": "Lokalt nätverk",
"local_network_sheet_info": "Appen kommer ansluta till servern via denna URL när det specificerade WiFi-nätverket används",
+ "location": "Position",
"location_permission": "Plats-rättighet",
"location_permission_content": "FÃļr att använda funktionen fÃļr automatisk växling behÃļver Immich behÃļrighet till exakt plats sÃĨ att appen kan läsa av det aktuella Wi-Fi-nätverkets namn",
"location_picker_choose_on_map": "Välj pÃĨ karta",
@@ -1694,6 +1697,7 @@
"reset_sqlite_confirmation": "Ãr du säker pÃĨ att du vill ÃĨterställa SQLite-databasen? Du mÃĨste logga ut och logga in igen fÃļr att synkronisera om data",
"reset_sqlite_success": "Ã
terställde SQLite-databasen",
"reset_to_default": "Ã
terställ till standard",
+ "resolution": "UpplÃļsning",
"resolve_duplicates": "LÃļs dubletter",
"resolved_all_duplicates": "LÃļs alla dubletter",
"restore": "Ã
terställ",
@@ -1712,6 +1716,7 @@
"running": "IgÃĨngsatt",
"save": "Spara",
"save_to_gallery": "Spara i galleri",
+ "saved": "Sparad",
"saved_api_key": "Sparad API-nyckel",
"saved_profile": "Sparade profil",
"saved_settings": "Sparade inställningar",
@@ -2020,6 +2025,7 @@
"theme_setting_three_stage_loading_title": "Aktivera trestegsladdning",
"they_will_be_merged_together": "De kommer att slÃĨs samman",
"third_party_resources": "Tredjepartsresurser",
+ "time": "Tid",
"time_based_memories": "Tidsbaserade minnen",
"timeline": "Tidslinje",
"timezone": "Tidszon",
diff --git a/i18n/ta.json b/i18n/ta.json
index cca12c4d76..90a7f8e214 100644
--- a/i18n/ta.json
+++ b/i18n/ta.json
@@ -154,6 +154,18 @@
"machine_learning_min_detection_score_description": "āŽāŽ°ā¯ āŽŽā¯āŽāŽŽā¯ 0-1 āŽŽā¯āŽ¤āŽ˛ā¯ āŽāŽŖā¯āŽāŽąāŽŋāŽ¯āŽĒā¯āŽĒāŽā¯āŽĩāŽ¤āŽąā¯āŽā¯ āŽā¯āŽąā¯āލā¯āޤāŽĒāŽā¯āŽ āŽ¨āŽŽā¯āŽĒāŽŋāŽā¯āŽā¯ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āŽŖā¯. āŽā¯āŽąā¯āލā¯āޤ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āŽā޺❠āŽ
āŽ¤āŽŋāŽ āŽŽā¯āŽāŽā¯āŽāŽŗā¯āŽā¯ āŽāŽŖā¯āŽāŽąāŽŋāŽ¯ā¯āŽŽā¯, āŽāŽŠāŽžāŽ˛ā¯ āŽ¤āŽĩāŽąāŽžāŽŠ āŽ¨ā¯āްā¯āŽŽāŽąā¯āŽā޺❠āŽāŽąā¯āŽĒāŽā¯āޤā¯āޤāŽā¯āŽā¯āŽā¯āŽŽā¯.",
"machine_learning_min_recognized_faces": "āŽā¯āŽąā¯āލā¯āޤāŽĒāŽā¯āŽ āŽ
āŽā¯āŽā¯āŽāްāŽŋāŽā¯āŽāŽĒā¯āŽĒāŽā¯āŽ āŽŽā¯āŽāŽā¯āŽāŽŗā¯",
"machine_learning_min_recognized_faces_description": "āŽāŽ°ā¯ āŽ¨āŽĒāŽ°ā¯āŽā¯āŽā¯ āŽāްā¯āŽĩāŽžāŽā¯āŽāŽĒā¯āŽĒāŽ āŽĩā¯āŽŖā¯āŽāŽŋāŽ¯ āŽ
āŽā¯āŽā¯āŽāްāŽŋāŽā¯āŽāŽĒā¯āŽĒāŽā¯āŽ āŽŽā¯āŽāŽā¯āŽāŽŗāŽŋāŽŠā¯ āŽā¯āŽąā¯āލā¯āޤāŽĒāŽā¯āŽ āŽāŽŖā¯āŽŖāŽŋāŽā¯āŽā¯. āŽāޤ❠āŽ
āŽ¤āŽŋāŽāްāŽŋāŽĒā¯āŽĒāŽ¤ā¯, āŽāŽ°ā¯ āŽ¨āŽĒāŽ°ā¯āŽā¯āŽā¯ āŽŽā¯āŽāŽŽā¯ āŽāޤā¯āŽā¯āŽāŽĒā¯āŽĒāŽāŽžāŽŽāŽ˛ā¯ āŽĒā¯āŽā¯āŽŽā¯ āŽĩāŽžāŽ¯ā¯āŽĒā¯āŽĒ❠āŽ
āŽ¤āŽŋāŽāްāŽŋāŽā¯āŽā¯āŽŽā¯ āŽā¯āޞāŽĩāŽŋāŽ˛ā¯, āŽŽā¯āŽ āŽ
āŽā¯āŽā¯āŽāŽžāŽ°āŽ¤ā¯āŽ¤ā¯ āŽŽāŽŋāŽāŽĩā¯āŽŽā¯ āŽ¤ā¯āޞā¯āޞāŽŋāŽ¯āŽŽāŽžāŽā¯āŽā¯āŽāŽŋāŽąāŽ¤ā¯.",
+ "machine_learning_ocr": "āŽāŽāŽŋāŽāްā¯",
+ "machine_learning_ocr_description": "āŽĒāŽāŽā¯āŽāŽŗāŽŋāŽ˛ā¯ āŽāŽŗā¯āŽŗ āŽāްā¯āޝ❠āŽ
āŽā¯āŽ¯āŽžāŽŗāŽŽā¯ āŽāŽžāŽŖ āŽāŽ¯āŽ¨ā¯āޤāŽŋāŽ° āŽāŽąā¯āŽąāŽ˛ā¯āŽĒ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤāŽĩā¯āŽŽā¯",
+ "machine_learning_ocr_enabled": "āŽāŽāŽŋāŽāŽ°ā¯ āŽ āŽāŽ¯āŽ˛āŽā¯āŽā¯āޝā¯",
+ "machine_learning_ocr_enabled_description": "āŽŽā¯āŽāŽā¯āŽāŽĒā¯āŽĒāŽā¯āŽāŽžāŽ˛ā¯, āŽĒāŽāŽā¯āŽā޺❠āŽāް❠āŽ
āŽā¯āŽ¯āŽžāŽŗāŽŽā¯ āŽāŽžāŽŖāŽĒā¯āŽĒāŽāŽžāŽ¤ā¯.",
+ "machine_learning_ocr_max_resolution": "āŽ
āŽ¤āŽŋāŽāŽĒāŽā¯āŽ āŽ¤ā¯āŽŗāŽŋāŽĩā¯āޤā¯āޤāŽŋāŽąāŽŠā¯",
+ "machine_learning_ocr_max_resolution_description": "āŽāލā¯āŽ¤āŽ¤ā¯ āŽ¤ā¯āŽŗāŽŋāŽĩā¯āޤā¯āޤāŽŋāŽąāŽŠā¯āŽā¯āŽā¯ āŽŽā¯āޞ❠āŽāŽŗā¯āŽŗ āŽŽāŽžāŽ¤āŽŋāŽ°āŽŋāŽā¯āŽāŽžāŽā¯āŽāŽŋāŽāŽŗā¯, āŽ¤ā¯āŽąā¯āŽą āŽĩāŽŋāŽāŽŋāŽ¤āŽ¤ā¯āޤā¯āŽĒ❠āŽĒāŽžāŽ¤ā¯āŽāŽžāŽā¯āŽā¯āŽŽā¯ āŽ
āŽ¤ā¯ āŽĩā¯āŽŗā¯āޝāŽŋāŽ˛ā¯, āŽ
āŽŗāŽĩā¯ āŽŽāŽžāŽąā¯āŽąāŽĒā¯āŽĒāŽā¯āŽŽā¯. āŽ
āŽ¤āŽŋāŽ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āŽāŽŗā¯ āŽŽāŽŋāŽāŽĩā¯āŽŽā¯ āŽ¤ā¯āޞā¯āޞāŽŋāŽ¯āŽŽāŽžāŽŠāŽĩā¯, āŽāŽŠāŽžāŽ˛ā¯ āŽā¯āŽ¯āŽ˛āŽžāŽā¯āŽāŽĩā¯āŽŽā¯ āŽ
āŽ¤āŽŋāŽ āŽ¨āŽŋāŽŠā¯āŽĩāŽāޤā¯āޤā¯āŽĒ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤāŽĩā¯āŽŽā¯ āŽ
āŽ¤āŽŋāŽ āŽ¨ā¯āŽ°āŽŽā¯ āŽāŽā¯āŽā¯āŽā¯āŽŽā¯.",
+ "machine_learning_ocr_min_detection_score": "āŽā¯āŽąā¯āލā¯āޤāŽĒāŽā¯āŽ āŽāŽŖā¯āŽāŽąāŽŋāŽ¤āŽ˛ā¯ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āŽŖā¯",
+ "machine_learning_ocr_min_detection_score_description": "āŽāްā¯āޝā¯āŽā¯ āŽāŽŖā¯āŽāŽąāŽŋāŽ¯ āŽā¯āŽąā¯āލā¯āޤāŽĒāŽā¯āŽ āŽ¨āŽŽā¯āŽĒāŽŋāŽā¯āŽā¯ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āު❠0-1. āŽā¯āŽąā¯āލā¯āޤ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āު❠āŽ
āŽ¤āŽŋāŽ āŽāްā¯āޝā¯āŽā¯ āŽāŽŖā¯āŽāŽąāŽŋāŽ¯ā¯āŽŽā¯, āŽāŽŠāŽžāŽ˛ā¯ āŽ¤āŽĩāŽąāŽžāŽŠ āŽ¤āŽāŽĩāŽ˛ā¯āŽā¯āŽā¯ āŽĩāŽ´āŽŋāŽĩāŽā¯āŽā¯āŽā¯āŽŽā¯.",
+ "machine_learning_ocr_min_recognition_score": "āŽā¯āŽąā¯āލā¯āޤāŽĒāŽā¯āŽ āŽ
āŽā¯āŽā¯āŽāŽžāŽ° āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āŽŖā¯",
+ "machine_learning_ocr_min_score_recognition_description": "āŽāŽŖā¯āŽāŽąāŽŋāŽ¯āŽĒā¯āŽĒāŽā¯āŽ āŽāްā¯āޝ❠āŽ
āŽā¯āŽā¯āŽāްāŽŋāŽā¯āŽ āŽā¯āŽąā¯āލā¯āޤāŽĒāŽā¯āŽ āŽ¨āŽŽā¯āŽĒāŽŋāŽā¯āŽā¯ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āު❠0-1 āŽāŽā¯āŽŽā¯. āŽā¯āŽąā¯āލā¯āޤ āŽŽāŽ¤āŽŋāŽĒā¯āŽĒā¯āު❠āŽ
āŽ¤āŽŋāŽ āŽāްā¯āޝ❠āŽ
āŽā¯āŽā¯āŽāްāŽŋāŽā¯āŽā¯āŽŽā¯, āŽāŽŠāŽžāŽ˛ā¯ āŽ¤āŽĩāŽąāŽžāŽŠ āŽ¨ā¯āްā¯āŽŽāŽąā¯āŽāŽŗā¯āŽā¯āŽā¯ āŽĩāŽ´āŽŋāŽĩāŽā¯āŽā¯āŽā¯āŽŽā¯.",
+ "machine_learning_ocr_model": "āŽāŽāŽŋāŽāŽ°ā¯ āŽŽāŽžāŽ¤āŽŋāŽ°āŽŋ",
+ "machine_learning_ocr_model_description": "āŽŽā¯āŽĒā¯āŽ˛ā¯ āŽŽāŽžāŽāޞā¯āŽā޺❠āŽĩāŽŋāŽ āŽāްā¯āŽĩāŽ°ā¯ āŽŽāŽžāŽāޞā¯āŽāŽŗā¯ āŽŽāŽŋāŽāŽĩā¯āŽŽā¯ āŽ¤ā¯āޞā¯āޞāŽŋāŽ¯āŽŽāŽžāŽŠāŽĩā¯, āŽāŽŠāŽžāŽ˛ā¯ āŽ
āŽ¤āŽŋāŽ āŽ¨āŽŋāŽŠā¯āŽĩāŽāޤā¯āޤ❠āŽā¯āŽ¯āŽ˛āŽžāŽā¯āŽāŽŋ āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤ āŽ
āŽ¤āŽŋāŽ āŽ¨ā¯āŽ°āŽŽā¯ āŽāŽā¯āŽā¯āŽā¯āŽŽā¯.",
"machine_learning_settings": "āŽāŽ¯āŽ¨ā¯āޤāŽŋāŽ° āŽāŽąā¯āŽąāŽ˛ā¯ āŽ
āŽŽā¯āŽĒā¯āŽĒā¯āŽāŽŗā¯",
"machine_learning_settings_description": "āŽāŽ¯āŽ¨ā¯āޤāŽŋāŽ° āŽāŽąā¯āŽąāŽ˛ā¯ āŽ
āŽŽā¯āŽāŽā¯āŽāŽŗā¯ āŽŽāŽąā¯āŽąā¯āŽŽā¯ āŽ
āŽŽā¯āŽĒā¯āŽĒā¯āŽāŽŗā¯ āŽ¨āŽŋāŽ°ā¯āŽĩāŽāŽŋāŽā¯āŽāŽĩā¯āŽŽā¯",
"machine_learning_smart_search": "āŽ¸ā¯āŽŽāŽžāŽ°ā¯āŽā¯ āŽ¤ā¯āŽāޞā¯",
@@ -245,6 +257,7 @@
"oauth_storage_quota_default_description": "GiB āŽāޞ❠āŽāŽŗā¯āŽŗ āŽāޤā¯āŽā¯āŽā¯āŽā¯ āŽāލā¯āޤ āŽāްāŽŋāŽŽā¯āŽā¯āŽ°āŽ˛ā¯āŽŽā¯ āŽĩāŽ´āŽā¯āŽāŽĒā¯āŽĒāŽāŽžāŽ¤āŽĒā¯āޤ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤāŽĒā¯āŽĒāŽā¯āŽŽā¯ .",
"oauth_timeout": "āŽā¯āްāŽŋāŽā¯āŽā¯ āŽ¨ā¯āŽ°āŽŽā¯ āŽŽā¯āŽāŽŋāŽ¨ā¯āŽ¤āŽ¤ā¯",
"oauth_timeout_description": "āŽā¯āްāŽŋāŽā¯āŽā¯āŽāŽŗā¯āŽā¯āŽāŽžāŽŠ āŽāŽžāŽ˛āŽā¯āŽā¯āŽā¯ āŽŽāŽŋāŽ˛ā¯āޞāŽŋ āŽĩāŽŋāŽŠāŽžāŽāŽŋāŽāŽŗāŽŋāŽ˛ā¯",
+ "ocr_job_description": "āŽĒāŽāŽā¯āŽāŽŗāŽŋāŽ˛ā¯ āŽāŽŗā¯āŽŗ āŽāްā¯āޝ❠āŽ
āŽā¯āŽ¯āŽžāŽŗāŽŽā¯ āŽāŽžāŽŖ āŽāŽ¯āŽ¨ā¯āޤāŽŋāŽ° āŽāŽąā¯āŽąāŽ˛ā¯āŽĒ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤāŽĩā¯āŽŽā¯",
"password_enable_description": "āŽŽāŽŋāŽŠā¯āŽŠāŽā¯āŽāŽ˛ā¯ āŽŽāŽąā¯āŽąā¯āŽŽā¯ āŽāŽāŽĩā¯āŽā¯āŽā¯āŽ˛ā¯ āŽŽā¯āŽ˛āŽŽā¯ āŽāŽŗā¯āލā¯āŽ´ā¯āޝāŽĩā¯āŽŽā¯",
"password_settings": "āŽāŽāŽĩā¯āŽā¯āŽā¯āޞ❠āŽāŽŗā¯āލā¯āŽ´ā¯āŽĩā¯",
"password_settings_description": "āŽāŽāŽĩā¯āŽā¯āŽā¯āޞ❠āŽāŽŗā¯āލā¯āŽ´ā¯āŽĩ❠āŽ
āŽŽā¯āŽĒā¯āŽĒā¯āŽāŽŗā¯ āŽ¨āŽŋāŽ°ā¯āŽĩāŽāŽŋāŽā¯āŽāŽĩā¯āŽŽā¯",
@@ -669,6 +682,8 @@
"change_password_description": "āŽ¨ā¯āŽā¯āŽā޺❠āŽāŽŖāŽŋāŽŠāŽŋāŽ¯āŽŋāŽ˛ā¯ āŽā¯āޝā¯āŽĒā¯āŽĒāŽŽāŽŋāŽā¯āŽĩāŽ¤ā¯ āŽāޤā¯āŽĩā¯ āŽŽā¯āŽ¤āŽ˛ā¯ āŽŽā¯āŽąā¯ āŽ
āŽ˛ā¯āŽ˛āŽ¤ā¯ āŽāŽā¯āŽā޺❠āŽāŽāŽĩā¯āŽā¯āŽā¯āޞā¯āŽ˛ā¯ āŽŽāŽžāŽąā¯āŽąā¯āŽĩāŽ¤āŽąā¯āŽāŽžāŽŠ āŽā¯āްāŽŋāŽā¯āŽā¯ āŽā¯āޝā¯āޝāŽĒā¯āŽĒāŽā¯āŽā¯āŽŗā¯āŽŗāŽ¤ā¯. āŽā¯ā޴❠āŽĒā¯āޤāŽŋāŽ¯ āŽāŽāŽĩā¯āŽā¯āŽā¯āޞā¯āޞ❠āŽāŽŗā¯āŽŗāŽŋāŽāŽĩā¯āŽŽā¯.",
"change_password_form_confirm_password": "āŽāŽāŽĩā¯āŽā¯āŽā¯āޞā¯āޞ❠āŽāŽąā¯āޤāŽŋāŽĒā¯āŽĒāŽā¯āޤā¯āޤāŽĩā¯āŽŽā¯",
"change_password_form_description": "āŽāޝ❠{name}, \n\nāŽ¨ā¯āŽā¯āŽā޺❠āŽāŽŖāŽŋāŽŠāŽŋāŽ¯āŽŋāŽ˛ā¯ āŽā¯āޝā¯āŽĒā¯āŽĒāŽŽāŽŋāŽā¯āŽĩāŽ¤ā¯ āŽāޤā¯āŽĩā¯ āŽŽā¯āŽ¤āŽ˛ā¯ āŽŽā¯āŽąā¯ āŽ
āŽ˛ā¯āŽ˛āŽ¤ā¯ āŽāŽā¯āŽā޺❠āŽāŽāŽĩā¯āŽā¯āŽā¯āޞā¯āŽ˛ā¯ āŽŽāŽžāŽąā¯āŽąā¯āŽĩāŽ¤āŽąā¯āŽāŽžāŽŠ āŽā¯āްāŽŋāŽā¯āŽā¯ āŽā¯āޝā¯āޝāŽĒā¯āŽĒāŽā¯āŽā¯āŽŗā¯āŽŗāŽ¤ā¯. āŽā¯ā޴❠āŽĒā¯āޤāŽŋāŽ¯ āŽāŽāŽĩā¯āŽā¯āŽā¯āޞā¯āޞ❠āŽāŽŗā¯āŽŗāŽŋāŽāŽĩā¯āŽŽā¯.",
+ "change_password_form_log_out": "āŽŽāŽąā¯āŽą āŽāޞā¯āŽ˛āŽž āŽāŽžāŽ¤āŽŠāŽā¯āŽāŽŗāŽŋāŽ˛āŽŋāŽ°ā¯āލā¯āޤā¯āŽŽā¯ āŽĩā¯āŽŗāŽŋāŽ¯ā¯āŽąā¯",
+ "change_password_form_log_out_description": "āŽŽāŽąā¯āŽą āŽāޞā¯āŽ˛āŽž āŽāŽžāŽ¤āŽŠāŽā¯āŽāŽŗāŽŋāŽ˛āŽŋāŽ°ā¯āލā¯āޤā¯āŽŽā¯ āŽĩā¯āŽŗāŽŋāŽ¯ā¯āŽą āŽĒāŽ°āŽŋāŽ¨ā¯āޤā¯āްā¯āŽā¯āŽāŽĒā¯āŽĒāŽā¯āŽāŽŋāŽąāŽ¤ā¯",
"change_password_form_new_password": "āŽĒā¯āޤāŽŋāŽ¯ āŽāŽāŽĩā¯āŽā¯āŽā¯āޞā¯",
"change_password_form_password_mismatch": "āŽāŽāŽĩā¯āŽā¯āŽā¯āŽąā¯āŽā޺❠āŽĒā¯āްā¯āލā¯āޤāŽĩāŽŋāŽ˛ā¯āޞā¯",
"change_password_form_reenter_new_password": "āŽĒā¯āޤāŽŋāŽ¯ āŽāŽāŽĩā¯āŽā¯āŽā¯āޞā¯āŽ˛ā¯ āŽŽā¯āŽŖā¯āŽā¯āŽŽā¯ āŽāŽŗā¯āŽŗāŽŋāŽāŽĩā¯āŽŽā¯",
@@ -776,6 +791,7 @@
"daily_title_text_date_year": "E, mmm dd, yyyy",
"dark": "āŽāްā¯āŽŖā¯āŽ",
"dark_theme": "āŽāްā¯āŽŖā¯āŽ āŽāްā¯āŽĒā¯āŽĒā¯āްā¯āŽŗā¯ āŽŽāŽžāŽąā¯āŽąāŽĩā¯āŽŽā¯",
+ "date": "āŽ¤ā¯āޤāŽŋ",
"date_after": "āŽ¤ā¯āޤāŽŋ",
"date_and_time": "āŽ¤ā¯āޤāŽŋ āŽŽāŽąā¯āŽąā¯āŽŽā¯ āŽ¨ā¯āŽ°āŽŽā¯",
"date_before": "āŽŽā¯āŽŠā¯ āŽ¤ā¯āޤāŽŋ",
@@ -1084,6 +1100,7 @@
"features_setting_description": "āŽĒāŽ¯āŽŠā¯āŽĒāŽžāŽā¯āŽā¯ āŽ
āŽŽā¯āŽāŽā¯āŽāŽŗā¯ āŽ¨āŽŋāŽ°ā¯āŽĩāŽāŽŋāŽā¯āŽāŽĩā¯āŽŽā¯",
"file_name": "āŽā¯āŽĒā¯āŽĒ❠āŽĒā¯āŽ¯āŽ°ā¯",
"file_name_or_extension": "āŽā¯āŽĒā¯āŽĒ❠āŽĒā¯āŽ¯āŽ°ā¯ āŽ
āŽ˛ā¯āŽ˛āŽ¤ā¯ āŽ¨ā¯āŽā¯āŽāŽŋāŽĒā¯āŽĒā¯",
+ "file_size": "āŽā¯āŽĒā¯āŽĒ❠āŽ
āŽŗāŽĩā¯",
"filename": "āŽā¯āŽĒā¯āŽĒā¯āŽĒā¯āŽĒā¯āŽ¯āŽ°ā¯",
"filetype": "āŽĒā¯āޞā¯āŽā¯āŽĒā¯",
"filter": "āŽĩāŽāŽŋāŽĒā¯āŽĒāŽŋ",
@@ -1247,6 +1264,7 @@
"local_media_summary": "āŽāŽŗā¯āŽŗāŽ āŽāŽāŽ āŽā¯āްā¯āŽā¯āŽāŽŽā¯",
"local_network": "āŽāŽŗā¯āŽŗāŽ āŽĒāŽŋāŽŖā¯āŽ¯āŽŽā¯",
"local_network_sheet_info": "āŽā¯āŽąāŽŋāŽĒā¯āŽĒāŽŋāŽā¯āŽ āŽĩā¯āŽāŽĒā¯ āŽ¨ā¯āŽā¯āŽĩā¯āްā¯āŽā¯āŽā¯āŽĒ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤā¯āŽŽā¯ āŽĒā¯āޤ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽžāŽā¯ āŽāލā¯āޤ āŽŽā¯āŽāŽĩāŽ°āŽŋ āŽŽā¯āŽ˛āŽŽā¯ āŽā¯āŽĩā¯āޝāŽāޤā¯āޤā¯āŽāŽŠā¯ āŽāŽŖā¯āŽā¯āŽāŽĒā¯āŽĒāŽā¯āŽŽā¯",
+ "location": "āŽāŽāŽŽā¯",
"location_permission": "āŽāްā¯āŽĒā¯āŽĒāŽŋāŽ āŽāŽā¯āŽĩā¯",
"location_permission_content": "āŽāŽā¯āŽā¯-āŽā¯āŽĩāŽŋāŽā¯āŽāŽŋāŽā¯ āŽ
āŽŽā¯āŽāޤā¯āޤā¯āŽĒ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤ, āŽāŽŽā¯āŽŽāŽŋāŽā¯āŽā¯ āŽ¤ā¯āޞā¯āޞāŽŋāŽ¯āŽŽāŽžāŽŠ āŽāްā¯āŽĒā¯āŽĒāŽŋāŽ āŽāŽā¯āŽĩā¯ āŽ¤ā¯āŽĩā¯, āŽāŽŠāŽĩ❠āŽāŽ¤ā¯ āŽ¤āŽąā¯āŽĒā¯āޤā¯āޝ āŽĩā¯āŽāŽĒā¯ āŽ¨ā¯āŽā¯āŽĩā¯āްā¯āŽā¯āŽāŽŋāŽŠā¯ āŽĒā¯āŽ¯āŽ°ā¯āŽĒ❠āŽĒāŽāŽŋāŽā¯āŽ āŽŽā¯āŽāŽŋāŽ¯ā¯āŽŽā¯",
"location_picker_choose_on_map": "āŽĩāŽ°ā¯āŽĒāŽāޤā¯āޤāŽŋāŽ˛ā¯ āŽ¤ā¯āްā¯āŽĩ❠āŽā¯āޝā¯āޝāŽĩā¯āŽŽā¯",
@@ -1435,6 +1453,7 @@
"oauth": "Oauth",
"obtainium_configurator": "āŽāŽĒā¯āŽā¯āޝā¯āŽŠāŽŋāŽ¯āŽŽā¯ āŽāŽā¯āŽāŽŽā¯āŽĒā¯āŽĒāŽžāŽŗāŽ°ā¯",
"obtainium_configurator_instructions": "Immich GitHub āŽĩā¯āŽŗāŽŋāŽ¯ā¯āŽā¯āŽāŽŋāŽ˛ā¯ āŽāްā¯āލā¯āŽ¤ā¯ āŽ¨ā¯āްāŽāŽŋāŽ¯āŽžāŽ āŽāŽŖā¯āŽā¯āŽ°āŽžāŽ¯ā¯āŽā¯ āŽĒāŽ¯āŽŠā¯āŽĒāŽžāŽā¯āŽā¯ āŽ¨āŽŋāŽąā¯āŽĩāŽĩā¯āŽŽā¯ āŽĒā¯āޤā¯āŽĒā¯āŽĒāŽŋāŽā¯āŽāŽĩā¯āŽŽā¯ Obtainium āŽāŽĒ❠āŽĒāŽ¯āŽŠā¯āŽĒāŽā¯āޤā¯āޤāŽĩā¯āŽŽā¯. āŽĒāŽ¨āŽŋāŽ āŽĩāŽŋāŽā¯āޝ❠āŽāްā¯āŽĩāŽžāŽā¯āŽāŽŋ, āŽāŽā¯āŽā޺❠āŽāŽĒā¯āŽā¯āޝā¯āŽŠāŽŋāŽ¯āŽŽā¯ āŽāŽŗā¯āŽŗāŽŽā¯āŽĩ❠āŽāŽŖā¯āŽĒā¯āŽĒ❠āŽāްā¯āŽĩāŽžāŽā¯āŽ āŽāŽ°ā¯ āŽŽāŽžāŽąā¯āŽĒāŽžāŽā¯āŽā¯āŽ¤ā¯ āŽ¤ā¯āްā¯āލā¯āޤā¯āŽā¯āŽā¯āŽāŽĩā¯āŽŽā¯",
+ "ocr": "āŽāŽāŽŋāŽāްā¯",
"official_immich_resources": "āŽāޤā¯āޤāŽŋāŽ¯ā¯āŽāŽĒā¯āްā¯āŽĩ āŽāŽŽā¯āŽŽāŽž āŽĩāŽŗāŽā¯āŽāŽŗā¯",
"offline": "āŽāŽŖā¯āŽ¯āŽŽāŽŋāŽ˛ā¯āŽ˛āŽžāŽŽāŽ˛ā¯",
"offset": "āŽāŽā¯āŽā¯āޝā¯āޝā¯āŽŽā¯",
@@ -1678,6 +1697,7 @@
"reset_sqlite_confirmation": "SQLITE āŽ¤āŽ°āŽĩā¯āޤā¯āŽ¤āŽŗāŽ¤ā¯āŽ¤ā¯ āŽŽā¯āŽā¯āŽāŽŽā¯āŽā¯āŽ āŽĩāŽŋāŽ°ā¯āŽŽā¯āŽĒā¯āŽāŽŋāŽąā¯āްā¯āŽāŽŗāŽž? āŽ¤āŽ°āŽĩā¯ āŽŽā¯āŽŖā¯āŽā¯āŽŽā¯ āŽāޤā¯āޤāŽŋāŽā¯āŽā¯āŽ āŽ¨ā¯āŽā¯āŽā޺❠āŽĩā¯āŽŗāŽŋāŽ¯ā¯āŽąāŽŋ āŽŽā¯āŽŖā¯āŽā¯āŽŽā¯ āŽāŽŗā¯āލā¯āŽ´ā¯āޝ āŽĩā¯āŽŖā¯āŽā¯āŽŽā¯",
"reset_sqlite_success": "SQLITE āŽ¤āŽ°āŽĩā¯āޤā¯āŽ¤āŽŗāŽ¤ā¯āޤ❠āŽĩā¯āŽąā¯āŽąāŽŋāŽāŽ°āŽŽāŽžāŽ āŽŽā¯āŽā¯āŽāŽŽā¯āŽā¯āŽāŽĩā¯āŽŽā¯",
"reset_to_default": "āŽāŽ¯āŽ˛ā¯āŽĒā¯āލāŽŋāŽ˛ā¯āŽā¯āŽā¯ āŽŽā¯āŽā¯āŽāŽŽā¯āŽā¯āŽāŽĩā¯āŽŽā¯",
+ "resolution": "āŽ¤ā¯āŽŗāŽŋāŽĩā¯āޤā¯āޤāŽŋāŽąāŽŠā¯",
"resolve_duplicates": "āŽ¨āŽāޞā¯āŽāŽŗā¯āŽ¤ā¯ āŽ¤ā¯āްā¯āŽā¯āŽāŽĩā¯āŽŽā¯",
"resolved_all_duplicates": "āŽ
āŽŠā¯āޤā¯āŽ¤ā¯ āŽ¨āŽāޞā¯āŽāŽŗā¯āޝā¯āŽŽā¯ āŽ¤ā¯āްā¯āŽā¯āŽā¯āŽŽā¯",
"restore": "āŽŽā¯āŽā¯āŽāŽŽā¯",
@@ -1696,6 +1716,7 @@
"running": "āŽāޝāŽā¯āŽā¯āŽŽā¯",
"save": "āŽā¯āŽŽāŽŋ",
"save_to_gallery": "āŽā¯āŽ˛āŽ°āŽŋāŽ¯āŽŋāŽ˛ā¯ āŽā¯āŽŽāŽŋāŽā¯āŽāŽĩā¯āŽŽā¯",
+ "saved": "āŽā¯āŽŽāŽŋāŽā¯āŽāŽĒā¯āŽĒāŽā¯āŽāޤā¯",
"saved_api_key": "āŽā¯āŽŽāŽŋāŽ¤ā¯āޤ āŽĒāŽ¨āŽŋāŽ āŽĩāŽŋāŽā¯",
"saved_profile": "āŽā¯āŽŽāŽŋāŽ¤ā¯āޤ āŽā¯āޝāŽĩāŽŋāŽĩāŽ°āŽŽā¯",
"saved_settings": "āŽā¯āŽŽāŽŋāŽ¤ā¯āޤ āŽ
āŽŽā¯āŽĒā¯āŽĒā¯āŽāŽŗā¯",
@@ -1712,6 +1733,8 @@
"search_by_description_example": "āŽāŽĒā¯āŽĒāŽžāŽĩāŽŋāŽ˛ā¯ āŽ¨āŽā¯āŽĒāŽ¯āŽŖāŽŽā¯",
"search_by_filename": "āŽā¯āŽĒā¯āŽĒ❠āŽĒā¯āŽ¯āŽ°ā¯ āŽ
āŽ˛ā¯āŽ˛āŽ¤ā¯ āŽ¨ā¯āŽā¯āŽāŽŋāŽĒā¯āŽĒā¯ āŽŽā¯āŽ˛āŽŽā¯ āŽ¤ā¯āŽā¯āŽā¯āŽāŽŗā¯",
"search_by_filename_example": "I.E. IMG_1234.JPG āŽ
āŽ˛ā¯āŽ˛āŽ¤ā¯ PNG",
+ "search_by_ocr": "āŽāŽāŽŋāŽāŽ°ā¯ āŽŽā¯āŽ˛āŽŽā¯ āŽ¤ā¯āŽā¯",
+ "search_by_ocr_example": "āŽ˛ā¯āŽā¯",
"search_camera_lens_model": "āŽāŽŖā¯āŽŖāŽžāŽāŽŋ āŽĩāŽŋāŽ˛ā¯āŽ˛ā¯ āŽŽāŽžāŽ¤āŽŋāŽ°āŽŋāŽ¯ā¯āŽ¤ā¯ āŽ¤ā¯āŽā¯...",
"search_camera_make": "āŽ¤ā¯āŽāޞ❠āŽā¯āŽŽāŽ°āŽž āŽā¯āޝā¯āޝā¯āŽā¯āŽā޺❠...",
"search_camera_model": "āŽā¯āŽŽāŽ°āŽž āŽŽāŽžāŽ¤āŽŋāŽ°āŽŋāŽ¯ā¯āŽ¤ā¯ āŽ¤ā¯āŽā¯āŽā¯āŽā޺❠...",
@@ -1729,6 +1752,7 @@
"search_filter_location_title": "āŽāްā¯āŽĒā¯āŽĒāŽŋāŽāޤā¯āޤā¯āŽ¤ā¯ āŽ¤ā¯āްā¯āލā¯āޤā¯āŽā¯āŽā¯āŽāŽĩā¯āŽŽā¯",
"search_filter_media_type": "āŽāŽāŽ āŽĩāŽā¯",
"search_filter_media_type_title": "āŽŽā¯āŽāŽŋāŽ¯āŽž āŽĩāŽā¯āޝā¯āŽ¤ā¯ āŽ¤ā¯āްā¯āލā¯āޤā¯āŽā¯āŽā¯āŽāŽĩā¯āŽŽā¯",
+ "search_filter_ocr": "āŽāŽāŽŋāŽāŽ°ā¯ āŽŽā¯āŽ˛āŽŽā¯ āŽ¤ā¯āŽā¯",
"search_filter_people_title": "āŽŽāŽā¯āŽāŽŗā¯āŽ¤ā¯ āŽ¤ā¯āްā¯āލā¯āޤā¯āŽā¯āŽā¯āŽāŽĩā¯āŽŽā¯",
"search_for": "āŽ¤ā¯āŽā¯āŽā¯āŽāŽŗā¯",
"search_for_existing_person": "āŽāްā¯āŽā¯āŽā¯āŽŽā¯ āŽ¨āŽĒāŽ°ā¯āŽ¤ā¯ āŽ¤ā¯āŽā¯āŽā¯āŽāŽŗā¯",
@@ -2001,6 +2025,7 @@
"theme_setting_three_stage_loading_title": "āŽŽā¯āŽŠā¯āŽąā¯-āŽ¨āŽŋāŽ˛ā¯ āŽāŽąā¯āŽąā¯āŽ¤āŽ˛ā¯ āŽāޝāŽā¯āŽāŽĩā¯āŽŽā¯",
"they_will_be_merged_together": "āŽ
āŽĩāŽ°ā¯āŽā޺❠āŽāŽŠā¯āŽąāŽžāŽ āŽāŽŖā¯āŽā¯āŽāŽĒā¯āŽĒāŽā¯āŽĩāŽžāŽ°ā¯āŽāŽŗā¯",
"third_party_resources": "āŽŽā¯āŽŠā¯āŽąāŽžāŽŽā¯ āŽ¤āŽ°āŽĒā¯āŽĒ❠āŽĩāŽŗāŽā¯āŽāŽŗā¯",
+ "time": "āŽ¨ā¯āŽ°āŽŽā¯",
"time_based_memories": "āŽ¨ā¯āް āŽ
āŽāŽŋāŽĒā¯āŽĒāŽā¯āޝāŽŋāŽ˛āŽžāŽŠ āŽ¨āŽŋāŽŠā¯āŽĩā¯āŽāŽŗā¯",
"timeline": "āŽāŽžāŽ˛āŽĩāŽ°āŽŋāŽā¯",
"timezone": "āŽ¨ā¯āް āŽŽāŽŖā¯āŽāŽ˛āŽŽā¯",
diff --git a/i18n/tr.json b/i18n/tr.json
index 84a246ddc5..1c1f4c7354 100644
--- a/i18n/tr.json
+++ b/i18n/tr.json
@@ -163,6 +163,7 @@
"machine_learning_ocr_min_detection_score": "En dÃŧÅÃŧk tespit puanÄą",
"machine_learning_ocr_min_detection_score_description": "Metnin tespit edilmesi için minimum gÃŧven puanÄą 0-1 arasÄąndadÄąr. DÃŧÅÃŧk deÄerler daha fazla metin tespit eder, ancak yanlÄąÅ pozitif sonuçlara yol açabilir.",
"machine_learning_ocr_min_recognition_score": "Minimum tespit puanÄą",
+ "machine_learning_ocr_model": "OCR modeli",
"machine_learning_settings": "Makine ÃÄrenmesi ayarlarÄą",
"machine_learning_settings_description": "Makine ÃļÄrenmesi Ãļzelliklerini ve ayarlarÄąnÄą yÃļnet",
"machine_learning_smart_search": "AkÄąllÄą Arama",
@@ -785,6 +786,7 @@
"daily_title_text_date_year": "dd MMM yyyy E",
"dark": "Koyu",
"dark_theme": "KaranlÄąk temaya geç",
+ "date": "Tarih",
"date_after": "Sonraki tarih",
"date_and_time": "Tarih ve Zaman",
"date_before": "Ãnceki tarih",
@@ -1093,6 +1095,7 @@
"features_setting_description": "UygulamanÄąn Ãļzelliklerini yÃļnet",
"file_name": "Dosya adÄą",
"file_name_or_extension": "Dosya adÄą veya uzantÄą",
+ "file_size": "Dosya boyutu",
"filename": "Dosya adÄą",
"filetype": "Dosya tipi",
"filter": "Filtre",
@@ -1444,6 +1447,7 @@
"oauth": "OAuth",
"obtainium_configurator": "Obtainium YapÄąlandÄąrÄącÄą",
"obtainium_configurator_instructions": "Obtainium kullanarak Android uygulamasÄąnÄą doÄrudan Immich GitHub sÃŧrÃŧmÃŧnden yÃŧkleyin ve gÃŧncelleyin. Bir API anahtarÄą oluÅturun ve bir varyant seçerek Obtainium yapÄąlandÄąrma baÄlantÄąnÄązÄą oluÅturun",
+ "ocr": "OCR",
"official_immich_resources": "Resmi Immich KaynaklarÄą",
"offline": "Ãevrim dÄąÅÄą",
"offset": "Ofset",
@@ -1687,6 +1691,7 @@
"reset_sqlite_confirmation": "SQLite veritabanÄąnÄą sÄąfÄąrlamak istediÄinizden emin misiniz? Verileri yeniden eÅzamanlamak için oturumu kapatÄąp tekrar oturum açmanÄąz gerekecektir",
"reset_sqlite_success": "SQLite veritabanÄąnÄą baÅarÄąyla sÄąfÄąrladÄąnÄąz",
"reset_to_default": "VarsayÄąlana sÄąfÄąrla",
+ "resolution": "ÃÃļzÃŧnÃŧrlÃŧk",
"resolve_duplicates": "Ãiftleri çÃļz",
"resolved_all_duplicates": "TÃŧm çiftler çÃļzÃŧldÃŧ",
"restore": "Geri yÃŧkle",
@@ -2010,6 +2015,7 @@
"theme_setting_three_stage_loading_title": "Ãç aÅamalÄą yÃŧklemeyi etkinleÅtir",
"they_will_be_merged_together": "Birlikte birleÅtirilecekler",
"third_party_resources": "ÃçÃŧncÃŧ taraf kaynaklar",
+ "time": "Zaman",
"time_based_memories": "Zaman bazlÄą anÄąlar",
"timeline": "Zaman Ãizelgesi",
"timezone": "Zaman dilimi",
diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json
index 16c6e9c926..8baeaec7d8 100644
--- a/i18n/zh_Hant.json
+++ b/i18n/zh_Hant.json
@@ -154,6 +154,8 @@
"machine_learning_min_detection_score_description": "čååĩæ¸ŦįæäŊäŋĄåŋ忏īŧį¯åįē 0 čŗ 1ãæ¸åŧčŧäŊææåĩæ¸Ŧå°æ´å¤čåīŧäŊå¯čŊå°č´čǤå¤ã",
"machine_learning_min_recognized_faces": "æäŊčé¨čž¨čæ¸é",
"machine_learning_min_recognized_faces_description": "åģēįĢæ°äēēįŠæéįæäŊåˇ˛čž¨čč忏éãæéĢæ¤æ¸åŧå¯čŽčåčž¨čæ´į˛žįĸēīŧäŊåææåĸå čåæĒčĸĢææ´žįĩĻäģģäŊäēēįŠįå¯čŊæ§ã",
+ "machine_learning_ocr": "æå螨č(OCR)",
+ "machine_learning_ocr_description": "äŊŋ፿Šå¨å¸įŋäžčåĨåįä¸įæå",
"machine_learning_settings": "æŠå¨å¸įŋč¨åŽ",
"machine_learning_settings_description": "įŽĄįæŠå¨å¸įŋįåčŊåč¨åŽ",
"machine_learning_smart_search": "æēæ
§æå°",
@@ -245,6 +247,7 @@
"oauth_storage_quota_default_description": "æĒæäžåŽŖåææäŊŋį¨įé
éĄīŧGiBīŧã",
"oauth_timeout": "čĢæąéžæ",
"oauth_timeout_description": "čĢæąįéžææéīŧæ¯Ģį§īŧ",
+ "ocr_job_description": "äŊŋ፿Šå¨å¸įŋäžčåĨååä¸įæå",
"password_enable_description": "äŊŋį¨éģåéĩäģļåå¯įĸŧįģå
Ĩ",
"password_settings": "å¯įĸŧįģå
Ĩ",
"password_settings_description": "įŽĄįå¯įĸŧįģå
Ĩč¨åŽ",
diff --git a/machine-learning/immich_ml/config.py b/machine-learning/immich_ml/config.py
index 68d00625a3..19fd5300df 100644
--- a/machine-learning/immich_ml/config.py
+++ b/machine-learning/immich_ml/config.py
@@ -13,6 +13,8 @@ from rich.logging import RichHandler
from uvicorn import Server
from uvicorn.workers import UvicornWorker
+from .schemas import ModelPrecision
+
class ClipSettings(BaseModel):
textual: str | None = None
@@ -24,6 +26,11 @@ class FacialRecognitionSettings(BaseModel):
detection: str | None = None
+class OcrSettings(BaseModel):
+ recognition: str | None = None
+ detection: str | None = None
+
+
class PreloadModelData(BaseModel):
clip_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__CLIP", None)
facial_recognition_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION", None)
@@ -37,6 +44,7 @@ class PreloadModelData(BaseModel):
del os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION"]
clip: ClipSettings = ClipSettings()
facial_recognition: FacialRecognitionSettings = FacialRecognitionSettings()
+ ocr: OcrSettings = OcrSettings()
class MaxBatchSize(BaseModel):
@@ -70,6 +78,7 @@ class Settings(BaseSettings):
rknn_threads: int = 1
preload: PreloadModelData | None = None
max_batch_size: MaxBatchSize | None = None
+ openvino_precision: ModelPrecision = ModelPrecision.FP32
@property
def device_id(self) -> str:
diff --git a/machine-learning/immich_ml/main.py b/machine-learning/immich_ml/main.py
index 35f04d77ef..3d34d9bf9d 100644
--- a/machine-learning/immich_ml/main.py
+++ b/machine-learning/immich_ml/main.py
@@ -103,6 +103,20 @@ async def preload_models(preload: PreloadModelData) -> None:
ModelTask.FACIAL_RECOGNITION,
)
+ if preload.ocr.detection is not None:
+ await load_models(
+ preload.ocr.detection,
+ ModelType.DETECTION,
+ ModelTask.OCR,
+ )
+
+ if preload.ocr.recognition is not None:
+ await load_models(
+ preload.ocr.recognition,
+ ModelType.RECOGNITION,
+ ModelTask.OCR,
+ )
+
if preload.clip_fallback is not None:
log.warning(
"Deprecated env variable: 'MACHINE_LEARNING_PRELOAD__CLIP'. "
diff --git a/machine-learning/immich_ml/models/constants.py b/machine-learning/immich_ml/models/constants.py
index 10a4ae48a9..db9e7cfa4d 100644
--- a/machine-learning/immich_ml/models/constants.py
+++ b/machine-learning/immich_ml/models/constants.py
@@ -78,6 +78,14 @@ _INSIGHTFACE_MODELS = {
_PADDLE_MODELS = {
"PP-OCRv5_server",
"PP-OCRv5_mobile",
+ "CH__PP-OCRv5_server",
+ "CH__PP-OCRv5_mobile",
+ "EL__PP-OCRv5_mobile",
+ "EN__PP-OCRv5_mobile",
+ "ESLAV__PP-OCRv5_mobile",
+ "KOREAN__PP-OCRv5_mobile",
+ "LATIN__PP-OCRv5_mobile",
+ "TH__PP-OCRv5_mobile",
}
SUPPORTED_PROVIDERS = [
diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py
index 235fcc677e..07a2f3cce2 100644
--- a/machine-learning/immich_ml/models/ocr/detection.py
+++ b/machine-learning/immich_ml/models/ocr/detection.py
@@ -1,8 +1,10 @@
from typing import Any
+import cv2
import numpy as np
+from numpy.typing import NDArray
from PIL import Image
-from rapidocr.ch_ppocr_det import TextDetector as RapidTextDetector
+from rapidocr.ch_ppocr_det.utils import DBPostProcess
from rapidocr.inference_engine.base import FileInfo, InferSession
from rapidocr.utils import DownloadFile, DownloadFileInput
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
@@ -10,11 +12,10 @@ 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
+from .schemas import TextDetectionOutput
class TextDetector(InferenceModel):
@@ -22,15 +23,22 @@ class TextDetector(InferenceModel):
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)
+ super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
self.max_resolution = 736
- self.min_score = 0.5
- self.score_mode = "fast"
+ self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
+ self.std_inv = np.float32(1.0) / (np.array([0.5, 0.5, 0.5], dtype=np.float32) * 255.0)
self._empty: TextDetectionOutput = {
- "image": np.empty(0, dtype=np.float32),
"boxes": np.empty(0, dtype=np.float32),
"scores": np.empty(0, dtype=np.float32),
}
+ self.postprocess = DBPostProcess(
+ thresh=0.3,
+ box_thresh=model_kwargs.get("minScore", 0.5),
+ max_candidates=1000,
+ unclip_ratio=1.6,
+ use_dilation=True,
+ score_mode="fast",
+ )
def _download(self) -> None:
model_info = InferSession.get_model_url(
@@ -52,35 +60,65 @@ class TextDetector(InferenceModel):
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
+ return OrtSession(self.model_path)
- 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:
+ # partly adapted from RapidOCR
+ def _predict(self, inputs: Image.Image) -> TextDetectionOutput:
+ w, h = inputs.size
+ if w < 32 or h < 32:
+ return self._empty
+ out = self.session.run(None, {"x": self._transform(inputs)})[0]
+ boxes, scores = self.postprocess(out, (h, w))
+ if len(boxes) == 0:
return self._empty
return {
- "image": results.img,
- "boxes": np.array(results.boxes, dtype=np.float32),
- "scores": np.array(results.scores, dtype=np.float32),
+ "boxes": self.sorted_boxes(boxes),
+ "scores": np.array(scores, dtype=np.float32),
}
+ # adapted from RapidOCR
+ def _transform(self, img: Image.Image) -> NDArray[np.float32]:
+ if img.height < img.width:
+ ratio = float(self.max_resolution) / img.height
+ else:
+ ratio = float(self.max_resolution) / img.width
+
+ resize_h = int(img.height * ratio)
+ resize_w = int(img.width * ratio)
+
+ resize_h = int(round(resize_h / 32) * 32)
+ resize_w = int(round(resize_w / 32) * 32)
+ resized_img = img.resize((int(resize_w), int(resize_h)), resample=Image.Resampling.LANCZOS)
+
+ img_np: NDArray[np.float32] = cv2.cvtColor(np.array(resized_img, dtype=np.float32), cv2.COLOR_RGB2BGR) # type: ignore
+ img_np -= self.mean
+ img_np *= self.std_inv
+ img_np = np.transpose(img_np, (2, 0, 1))
+ return np.expand_dims(img_np, axis=0)
+
+ def sorted_boxes(self, dt_boxes: NDArray[np.float32]) -> NDArray[np.float32]:
+ if len(dt_boxes) == 0:
+ return dt_boxes
+
+ # Sort by y, then identify lines, then sort by (line, x)
+ y_order = np.argsort(dt_boxes[:, 0, 1], kind="stable")
+ sorted_y = dt_boxes[y_order, 0, 1]
+
+ line_ids = np.empty(len(dt_boxes), dtype=np.int32)
+ line_ids[0] = 0
+ np.cumsum(np.abs(np.diff(sorted_y)) >= 10, out=line_ids[1:])
+
+ # Create composite sort key for final ordering
+ # Shift line_ids by large factor, add x for tie-breaking
+ sort_key = line_ids[y_order] * 1e6 + dt_boxes[y_order, 0, 0]
+ final_order = np.argsort(sort_key, kind="stable")
+ sorted_boxes: NDArray[np.float32] = dt_boxes[y_order[final_order]]
+ return sorted_boxes
+
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
+ self.postprocess.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
+ self.postprocess.score_mode = score_mode
diff --git a/machine-learning/immich_ml/models/ocr/recognition.py b/machine-learning/immich_ml/models/ocr/recognition.py
index c3d39b0d70..af3f99dbdb 100644
--- a/machine-learning/immich_ml/models/ocr/recognition.py
+++ b/machine-learning/immich_ml/models/ocr/recognition.py
@@ -1,9 +1,8 @@
from typing import Any
-import cv2
import numpy as np
from numpy.typing import NDArray
-from PIL.Image import Image
+from PIL 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
@@ -14,6 +13,7 @@ from rapidocr.utils.vis_res import VisRes
from immich_ml.config import log, settings
from immich_ml.models.base import InferenceModel
+from immich_ml.models.transforms import pil_to_cv2
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
from immich_ml.sessions.ort import OrtSession
@@ -25,6 +25,7 @@ class TextRecognizer(InferenceModel):
identity = (ModelType.RECOGNITION, ModelTask.OCR)
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
+ self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
self.min_score = model_kwargs.get("minScore", 0.9)
self._empty: TextRecognitionOutput = {
"box": np.empty(0, dtype=np.float32),
@@ -41,7 +42,7 @@ class TextRecognizer(InferenceModel):
engine_type=EngineType.ONNXRUNTIME,
ocr_version=OCRVersion.PPOCRV5,
task_type=TaskType.REC,
- lang_type=LangRec.CH,
+ lang_type=self.language,
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
)
)
@@ -61,21 +62,21 @@ class TextRecognizer(InferenceModel):
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),
+ lang_type=self.language,
)
)
return session
- def _predict(self, _: Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
- boxes, img, box_scores = texts["boxes"], texts["image"], texts["scores"]
+ def _predict(self, img: Image.Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
+ boxes, box_scores = texts["boxes"], 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
+ boxes[:, :, 0] /= img.width
+ boxes[:, :, 1] /= img.height
text_scores = np.array(rec.scores)
valid_text_score_idx = text_scores > self.min_score
@@ -87,7 +88,7 @@ class TextRecognizer(InferenceModel):
"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]]:
+ def get_crop_img_list(self, img: Image.Image, boxes: NDArray[np.float32]) -> list[NDArray[np.uint8]]:
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)
@@ -98,22 +99,55 @@ class TextRecognizer(InferenceModel):
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]
+ img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1)
+ all_coeffs = self._get_perspective_transform(pts_std, boxes)
+ imgs: list[NDArray[np.uint8]] = []
+ for coeffs, dst_size in zip(all_coeffs, img_crop_sizes):
+ dst_img = img.transform(
+ size=tuple(dst_size),
+ method=Image.Transform.PERSPECTIVE,
+ data=tuple(coeffs),
+ resample=Image.Resampling.BICUBIC,
+ )
+
+ dst_width, dst_height = dst_img.size
if dst_height * 1.0 / dst_width >= 1.5:
- dst_img = np.rot90(dst_img)
- imgs.append(dst_img)
+ dst_img = dst_img.rotate(90, expand=True)
+ imgs.append(pil_to_cv2(dst_img))
+
return imgs
+ def _get_perspective_transform(self, src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.float32]:
+ N = src.shape[0]
+ x, y = src[:, :, 0], src[:, :, 1]
+ u, v = dst[:, :, 0], dst[:, :, 1]
+ A = np.zeros((N, 8, 9), dtype=np.float32)
+
+ # Fill even rows (0, 2, 4, 6): [x, y, 1, 0, 0, 0, -u*x, -u*y, -u]
+ A[:, ::2, 0] = x
+ A[:, ::2, 1] = y
+ A[:, ::2, 2] = 1
+ A[:, ::2, 6] = -u * x
+ A[:, ::2, 7] = -u * y
+ A[:, ::2, 8] = -u
+
+ # Fill odd rows (1, 3, 5, 7): [0, 0, 0, x, y, 1, -v*x, -v*y, -v]
+ A[:, 1::2, 3] = x
+ A[:, 1::2, 4] = y
+ A[:, 1::2, 5] = 1
+ A[:, 1::2, 6] = -v * x
+ A[:, 1::2, 7] = -v * y
+ A[:, 1::2, 8] = -v
+
+ # Solve using SVD for all matrices at once
+ _, _, Vt = np.linalg.svd(A)
+ H = Vt[:, -1, :].reshape(N, 3, 3)
+ H = H / H[:, 2:3, 2:3]
+
+ # Extract the 8 coefficients for each transformation
+ return np.column_stack(
+ [H[:, 0, 0], H[:, 0, 1], H[:, 0, 2], H[:, 1, 0], H[:, 1, 1], H[:, 1, 2], H[:, 2, 0], H[:, 2, 1]]
+ ) # pyright: ignore[reportReturnType]
+
def configure(self, **kwargs: Any) -> None:
self.min_score = kwargs.get("minScore", self.min_score)
diff --git a/machine-learning/immich_ml/models/ocr/schemas.py b/machine-learning/immich_ml/models/ocr/schemas.py
index 14a7d3cea0..78e8619a0b 100644
--- a/machine-learning/immich_ml/models/ocr/schemas.py
+++ b/machine-learning/immich_ml/models/ocr/schemas.py
@@ -7,7 +7,6 @@ from typing_extensions import TypedDict
class TextDetectionOutput(TypedDict):
- image: npt.NDArray[np.float32]
boxes: npt.NDArray[np.float32]
scores: npt.NDArray[np.float32]
@@ -21,8 +20,8 @@ class TextRecognitionOutput(TypedDict):
# RapidOCR expects `engine_type`, `lang_type`, and `font_path` to be attributes
class OcrOptions(dict[str, Any]):
- def __init__(self, **options: Any) -> None:
+ def __init__(self, lang_type: LangRec | None = None, **options: Any) -> None:
super().__init__(**options)
self.engine_type = EngineType.ONNXRUNTIME
- self.lang_type = LangRec.CH
+ self.lang_type = lang_type
self.font_path = None
diff --git a/machine-learning/immich_ml/schemas.py b/machine-learning/immich_ml/schemas.py
index bfb40b9c84..41706180de 100644
--- a/machine-learning/immich_ml/schemas.py
+++ b/machine-learning/immich_ml/schemas.py
@@ -46,6 +46,11 @@ class ModelSource(StrEnum):
PADDLE = "paddle"
+class ModelPrecision(StrEnum):
+ FP16 = "FP16"
+ FP32 = "FP32"
+
+
ModelIdentity = tuple[ModelType, ModelTask]
diff --git a/machine-learning/immich_ml/sessions/ort.py b/machine-learning/immich_ml/sessions/ort.py
index b6f709a323..6c52936722 100644
--- a/machine-learning/immich_ml/sessions/ort.py
+++ b/machine-learning/immich_ml/sessions/ort.py
@@ -93,10 +93,12 @@ class OrtSession:
case "CUDAExecutionProvider" | "ROCMExecutionProvider":
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
case "OpenVINOExecutionProvider":
+ openvino_dir = self.model_path.parent / "openvino"
+ device = f"GPU.{settings.device_id}"
options = {
- "device_type": f"GPU.{settings.device_id}",
- "precision": "FP32",
- "cache_dir": (self.model_path.parent / "openvino").as_posix(),
+ "device_type": device,
+ "precision": settings.openvino_precision.value,
+ "cache_dir": openvino_dir.as_posix(),
}
case "CoreMLExecutionProvider":
options = {
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index f2931baeb3..a93ab1c2af 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
-version = "2.2.0"
+version = "2.2.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0"
@@ -22,7 +22,6 @@ 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",
]
diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py
index 582a05a950..eb8706fc19 100644
--- a/machine-learning/test_main.py
+++ b/machine-learning/test_main.py
@@ -26,7 +26,7 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
from immich_ml.models.clip.visual import OpenClipVisualEncoder
from immich_ml.models.facial_recognition.detection import FaceDetector
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
-from immich_ml.schemas import ModelFormat, ModelTask, ModelType
+from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
from immich_ml.sessions.ann import AnnSession
from immich_ml.sessions.ort import OrtSession
from immich_ml.sessions.rknn import RknnSession, run_inference
@@ -240,11 +240,16 @@ class TestOrtSession:
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
def test_sets_default_provider_options(self, ov_device_ids: list[str]) -> None:
- model_path = "/cache/ViT-B-32__openai/model.onnx"
+ model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
+
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
assert session.provider_options == [
- {"device_type": "GPU.0", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"},
+ {
+ "device_type": "GPU.0",
+ "precision": "FP32",
+ "cache_dir": "/cache/ViT-B-32__openai/textual/openvino",
+ },
{"arena_extend_strategy": "kSameAsRequested"},
]
@@ -262,6 +267,21 @@ class TestOrtSession:
}
]
+ def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
+ model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
+ os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
+ mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
+
+ session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
+
+ assert session.provider_options == [
+ {
+ "device_type": "GPU.1",
+ "precision": "FP16",
+ "cache_dir": "/cache/ViT-B-32__openai/textual/openvino",
+ }
+ ]
+
def test_sets_provider_options_for_cuda(self) -> None:
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
@@ -417,7 +437,7 @@ class TestRknnSession:
session.run(None, input_feed)
rknn_session.return_value.put.assert_called_once_with([input1, input2])
- np_spy.call_count == 2
+ assert np_spy.call_count == 2
np_spy.assert_has_calls([mock.call(input1), mock.call(input2)])
@@ -925,11 +945,34 @@ class TestCache:
any_order=True,
)
+ async def test_preloads_ocr_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
+ os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile"
+ os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile"
+
+ settings = Settings()
+ assert settings.preload is not None
+ assert settings.preload.ocr.detection == "PP-OCRv5_mobile"
+ assert settings.preload.ocr.recognition == "PP-OCRv5_mobile"
+
+ model_cache = ModelCache()
+ monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
+
+ await preload_models(settings.preload)
+ mock_get_model.assert_has_calls(
+ [
+ mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR),
+ mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR),
+ ],
+ any_order=True,
+ )
+
async def test_preloads_all_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai"
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai"
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s"
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s"
+ os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile"
+ os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile"
settings = Settings()
assert settings.preload is not None
@@ -937,6 +980,8 @@ class TestCache:
assert settings.preload.clip.textual == "ViT-B-32__openai"
assert settings.preload.facial_recognition.recognition == "buffalo_s"
assert settings.preload.facial_recognition.detection == "buffalo_s"
+ assert settings.preload.ocr.detection == "PP-OCRv5_mobile"
+ assert settings.preload.ocr.recognition == "PP-OCRv5_mobile"
model_cache = ModelCache()
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
@@ -948,6 +993,8 @@ class TestCache:
mock.call("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH),
mock.call("buffalo_s", ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION),
mock.call("buffalo_s", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION),
+ mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR),
+ mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR),
],
any_order=True,
)
diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock
index caaa1e4467..919a17d45e 100644
--- a/machine-learning/uv.lock
+++ b/machine-learning/uv.lock
@@ -1100,7 +1100,6 @@ dependencies = [
{ name = "python-multipart" },
{ name = "rapidocr" },
{ name = "rich" },
- { name = "setuptools" },
{ name = "tokenizers" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -1188,7 +1187,6 @@ requires-dist = [
{ name = "rapidocr", specifier = ">=3.1.0" },
{ name = "rich", specifier = ">=13.4.2" },
{ name = "rknn-toolkit-lite2", marker = "extra == 'rknn'", specifier = ">=2.3.0,<3" },
- { name = "setuptools", specifier = ">=78.1.0" },
{ name = "tokenizers", specifier = ">=0.15.0,<1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" },
]
diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh
index 65a2e70e50..d0db83b946 100755
--- a/misc/release/pump-version.sh
+++ b/misc/release/pump-version.sh
@@ -88,7 +88,6 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
fi
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
-sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
diff --git a/mise.toml b/mise.toml
index b4ccd76565..cf3b86c6cc 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,7 +1,9 @@
+experimental_monorepo_root = true
+
[tools]
node = "24.11.0"
flutter = "3.35.7"
-pnpm = "10.19.0"
+pnpm = "10.20.0"
terragrunt = "0.91.2"
opentofu = "1.10.6"
@@ -14,514 +16,21 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
experimental = true
pin = true
-# .github
-[tasks."github:install"]
-run = "pnpm install --filter github --frozen-lockfile"
-
-[tasks."github:format"]
-env._.path = "./.github/node_modules/.bin"
-dir = ".github"
-run = "prettier --check ."
-
-[tasks."github:format-fix"]
-env._.path = "./.github/node_modules/.bin"
-dir = ".github"
-run = "prettier --write ."
-
-# @immich/cli
-[tasks."cli:install"]
-run = "pnpm install --filter @immich/cli --frozen-lockfile"
-
-[tasks."cli:build"]
-env._.path = "./cli/node_modules/.bin"
-dir = "cli"
-run = "vite build"
-
-[tasks."cli:test"]
-env._.path = "./cli/node_modules/.bin"
-dir = "cli"
-run = "vite"
-
-[tasks."cli:lint"]
-env._.path = "./cli/node_modules/.bin"
-dir = "cli"
-run = "eslint \"src/**/*.ts\" --max-warnings 0"
-
-[tasks."cli:lint-fix"]
-run = "mise run cli:lint --fix"
-
-[tasks."cli:format"]
-env._.path = "./cli/node_modules/.bin"
-dir = "cli"
-run = "prettier --check ."
-
-[tasks."cli:format-fix"]
-env._.path = "./cli/node_modules/.bin"
-dir = "cli"
-run = "prettier --write ."
-
-[tasks."cli:check"]
-env._.path = "./cli/node_modules/.bin"
-dir = "cli"
-run = "tsc --noEmit"
-
-# @immich/sdk
+# SDK tasks
[tasks."sdk:install"]
+dir = "open-api/typescript-sdk"
run = "pnpm install --filter @immich/sdk --frozen-lockfile"
[tasks."sdk:build"]
-env._.path = "./open-api/typescript-sdk/node_modules/.bin"
-dir = "./open-api/typescript-sdk"
+dir = "open-api/typescript-sdk"
+env._.path = "./node_modules/.bin"
run = "tsc"
-# docs
-[tasks."docs:install"]
-run = "pnpm install --filter documentation --frozen-lockfile"
-
-[tasks."docs:start"]
-env._.path = "./docs/node_modules/.bin"
-dir = "docs"
-run = "docusaurus --port 3005"
-
-[tasks."docs:build"]
-env._.path = "./docs/node_modules/.bin"
-dir = "docs"
-run = [
- "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
- "docusaurus build",
-]
-
-
-[tasks."docs:preview"]
-env._.path = "./docs/node_modules/.bin"
-dir = "docs"
-run = "docusaurus serve"
-
-
-[tasks."docs:format"]
-env._.path = "./docs/node_modules/.bin"
-dir = "docs"
-run = "prettier --check ."
-
-[tasks."docs:format-fix"]
-env._.path = "./docs/node_modules/.bin"
-dir = "docs"
-run = "prettier --write ."
-
-
-# e2e
-[tasks."e2e:install"]
-run = "pnpm install --filter immich-e2e --frozen-lockfile"
-
-[tasks."e2e:test"]
-env._.path = "./e2e/node_modules/.bin"
-dir = "e2e"
-run = "vitest --run"
-
-[tasks."e2e:test-web"]
-env._.path = "./e2e/node_modules/.bin"
-dir = "e2e"
-run = "playwright test"
-
-[tasks."e2e:format"]
-env._.path = "./e2e/node_modules/.bin"
-dir = "e2e"
-run = "prettier --check ."
-
-[tasks."e2e:format-fix"]
-env._.path = "./e2e/node_modules/.bin"
-dir = "e2e"
-run = "prettier --write ."
-
-[tasks."e2e:lint"]
-env._.path = "./e2e/node_modules/.bin"
-dir = "e2e"
-run = "eslint \"src/**/*.ts\" --max-warnings 0"
-
-[tasks."e2e:lint-fix"]
-run = "mise run e2e:lint --fix"
-
-[tasks."e2e:check"]
-env._.path = "./e2e/node_modules/.bin"
-dir = "e2e"
-run = "tsc --noEmit"
-
-# i18n
+# i18n tasks
[tasks."i18n:format"]
-run = "mise run i18n:format-fix"
+dir = "i18n"
+run = { task = ":i18n:format-fix" }
[tasks."i18n:format-fix"]
-run = "pnpm dlx sort-json ./i18n/*.json"
-
-
-# server
-[tasks."server:install"]
-run = "pnpm install --filter immich --frozen-lockfile"
-
-[tasks."server:build"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "nest build"
-
-[tasks."server:test"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "vitest --config test/vitest.config.mjs"
-
-[tasks."server:test-medium"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "vitest --config test/vitest.config.medium.mjs"
-
-[tasks."server:format"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "prettier --check ."
-
-[tasks."server:format-fix"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "prettier --write ."
-
-[tasks."server:lint"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
-
-[tasks."server:lint-fix"]
-run = "mise run server:lint --fix"
-
-[tasks."server:check"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "tsc --noEmit"
-
-[tasks."server:sql"]
-dir = "server"
-run = "node ./dist/bin/sync-open-api.js"
-
-[tasks."server:open-api"]
-dir = "server"
-run = "node ./dist/bin/sync-open-api.js"
-
-[tasks."server:migrations"]
-dir = "server"
-run = "node ./dist/bin/migrations.js"
-description = "Run database migration commands (create, generate, run, debug, or query)"
-
-[tasks."server:schema-drop"]
-run = "mise run server:migrations query 'DROP schema public cascade; CREATE schema public;'"
-
-[tasks."server:schema-reset"]
-run = "mise run server:schema-drop && mise run server:migrations run"
-
-[tasks."server:email-dev"]
-env._.path = "./server/node_modules/.bin"
-dir = "server"
-run = "email dev -p 3050 --dir src/emails"
-
-[tasks."server:checklist"]
-run = [
- "mise run server:install",
- "mise run server:format",
- "mise run server:lint",
- "mise run server:check",
- "mise run server:test-medium --run",
- "mise run server:test --run",
-]
-
-
-# web
-[tasks."web:install"]
-run = "pnpm install --filter immich-web --frozen-lockfile"
-
-[tasks."web:svelte-kit-sync"]
-env._.path = "./web/node_modules/.bin"
-dir = "web"
-run = "svelte-kit sync"
-
-[tasks."web:build"]
-env._.path = "./web/node_modules/.bin"
-dir = "web"
-run = "vite build"
-
-[tasks."web:build-stats"]
-env.BUILD_STATS = "true"
-env._.path = "./web/node_modules/.bin"
-dir = "web"
-run = "vite build"
-
-[tasks."web:preview"]
-env._.path = "./web/node_modules/.bin"
-dir = "web"
-run = "vite preview"
-
-[tasks."web:start"]
-env._.path = "web/node_modules/.bin"
-dir = "web"
-run = "vite dev --host 0.0.0.0 --port 3000"
-
-[tasks."web:test"]
-depends = "web:svelte-kit-sync"
-env._.path = "web/node_modules/.bin"
-dir = "web"
-run = "vitest"
-
-[tasks."web:format"]
-env._.path = "web/node_modules/.bin"
-dir = "web"
-run = "prettier --check ."
-
-[tasks."web:format-fix"]
-env._.path = "web/node_modules/.bin"
-dir = "web"
-run = "prettier --write ."
-
-[tasks."web:lint"]
-env._.path = "web/node_modules/.bin"
-dir = "web"
-run = "eslint . --max-warnings 0 --concurrency 4"
-
-[tasks."web:lint-fix"]
-run = "mise run web:lint --fix"
-
-[tasks."web:check"]
-depends = "web:svelte-kit-sync"
-env._.path = "web/node_modules/.bin"
-dir = "web"
-run = "tsc --noEmit"
-
-[tasks."web:check-svelte"]
-depends = "web:svelte-kit-sync"
-env._.path = "web/node_modules/.bin"
-dir = "web"
-run = "svelte-check --no-tsconfig --fail-on-warnings"
-
-[tasks."web:checklist"]
-run = [
- "mise run web:install",
- "mise run web:format",
- "mise run web:check",
- "mise run web:test --run",
- "mise run web:lint",
-]
-
-
-# mobile
-[tasks."mobile:codegen:dart"]
-alias = "mobile:codegen"
-description = "Execute build_runner to auto-generate dart code"
-dir = "mobile"
-sources = [
- "pubspec.yaml",
- "build.yaml",
- "lib/**/*.dart",
- "infrastructure/**/*.drift",
-]
-outputs = { auto = true }
-run = "dart run build_runner build --delete-conflicting-outputs"
-
-[tasks."mobile:codegen:pigeon"]
-alias = "mobile:pigeon"
-description = "Generate pigeon platform code"
-dir = "mobile"
-depends = [
- "mobile:pigeon:native-sync",
- "mobile:pigeon:thumbnail",
- "mobile:pigeon:background-worker",
- "mobile:pigeon:background-worker-lock",
- "mobile:pigeon:connectivity",
-]
-
-[tasks."mobile:codegen:translation"]
-alias = "mobile:translation"
-description = "Generate translations from i18n JSONs"
-dir = "mobile"
-run = [
- { task = "i18n:format-fix" },
- { tasks = [
- "mobile:i18n:loader",
- "mobile:i18n:keys",
- ] },
-]
-
-[tasks."mobile:codegen:app-icon"]
-description = "Generate app icons"
-dir = "mobile"
-run = "flutter pub run flutter_launcher_icons:main"
-
-[tasks."mobile:codegen:splash"]
-description = "Generate splash screen"
-dir = "mobile"
-run = "flutter pub run flutter_native_splash:create"
-
-[tasks."mobile:test"]
-description = "Run mobile tests"
-dir = "mobile"
-run = "flutter test"
-
-[tasks."mobile:lint"]
-description = "Analyze Dart code"
-dir = "mobile"
-depends = ["mobile:analyze:dart", "mobile:analyze:dcm"]
-
-[tasks."mobile:lint-fix"]
-description = "Auto-fix Dart code"
-dir = "mobile"
-depends = ["mobile:analyze:fix:dart", "mobile:analyze:fix:dcm"]
-
-[tasks."mobile:format"]
-description = "Format Dart code"
-dir = "mobile"
-run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))"
-
-[tasks."mobile:build:android"]
-description = "Build Android release"
-dir = "mobile"
-run = "flutter build appbundle"
-
-[tasks."mobile:drift:migration"]
-alias = "mobile:migration"
-description = "Generate database migrations"
-dir = "mobile"
-run = "dart run drift_dev make-migrations"
-
-
-# mobile internal tasks
-[tasks."mobile:pigeon:native-sync"]
-description = "Generate native sync API pigeon code"
-dir = "mobile"
-hide = true
-sources = ["pigeon/native_sync_api.dart"]
-outputs = [
- "lib/platform/native_sync_api.g.dart",
- "ios/Runner/Sync/Messages.g.swift",
- "android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt",
-]
-run = [
- "dart run pigeon --input pigeon/native_sync_api.dart",
- "dart format lib/platform/native_sync_api.g.dart",
-]
-
-[tasks."mobile:pigeon:thumbnail"]
-description = "Generate thumbnail API pigeon code"
-dir = "mobile"
-hide = true
-sources = ["pigeon/thumbnail_api.dart"]
-outputs = [
- "lib/platform/thumbnail_api.g.dart",
- "ios/Runner/Images/Thumbnails.g.swift",
- "android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt",
-]
-run = [
- "dart run pigeon --input pigeon/thumbnail_api.dart",
- "dart format lib/platform/thumbnail_api.g.dart",
-]
-
-[tasks."mobile:pigeon:background-worker"]
-description = "Generate background worker API pigeon code"
-dir = "mobile"
-hide = true
-sources = ["pigeon/background_worker_api.dart"]
-outputs = [
- "lib/platform/background_worker_api.g.dart",
- "ios/Runner/Background/BackgroundWorker.g.swift",
- "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt",
-]
-run = [
- "dart run pigeon --input pigeon/background_worker_api.dart",
- "dart format lib/platform/background_worker_api.g.dart",
-]
-
-[tasks."mobile:pigeon:background-worker-lock"]
-description = "Generate background worker lock API pigeon code"
-dir = "mobile"
-hide = true
-sources = ["pigeon/background_worker_lock_api.dart"]
-outputs = [
- "lib/platform/background_worker_lock_api.g.dart",
- "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt",
-]
-run = [
- "dart run pigeon --input pigeon/background_worker_lock_api.dart",
- "dart format lib/platform/background_worker_lock_api.g.dart",
-]
-
-[tasks."mobile:pigeon:connectivity"]
-description = "Generate connectivity API pigeon code"
-dir = "mobile"
-hide = true
-sources = ["pigeon/connectivity_api.dart"]
-outputs = [
- "lib/platform/connectivity_api.g.dart",
- "ios/Runner/Connectivity/Connectivity.g.swift",
- "android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt",
-]
-run = [
- "dart run pigeon --input pigeon/connectivity_api.dart",
- "dart format lib/platform/connectivity_api.g.dart",
-]
-
-[tasks."mobile:i18n:loader"]
-description = "Generate i18n loader"
-dir = "mobile"
-hide = true
-sources = ["i18n/"]
-outputs = "lib/generated/codegen_loader.g.dart"
-run = [
- "dart run easy_localization:generate -S ../i18n",
- "dart format lib/generated/codegen_loader.g.dart",
-]
-
-[tasks."mobile:i18n:keys"]
-description = "Generate i18n keys"
-dir = "mobile"
-hide = true
-sources = ["i18n/en.json"]
-outputs = "lib/generated/intl_keys.g.dart"
-run = [
- "dart run bin/generate_keys.dart",
- "dart format lib/generated/intl_keys.g.dart",
-]
-
-[tasks."mobile:analyze:dart"]
-description = "Run Dart analysis"
-dir = "mobile"
-hide = true
-run = "dart analyze --fatal-infos"
-
-[tasks."mobile:analyze:dcm"]
-description = "Run Dart Code Metrics"
-dir = "mobile"
-hide = true
-run = "dcm analyze lib --fatal-style --fatal-warnings"
-
-[tasks."mobile:analyze:fix:dart"]
-description = "Auto-fix Dart analysis"
-dir = "mobile"
-hide = true
-run = "dart fix --apply"
-
-[tasks."mobile:analyze:fix:dcm"]
-description = "Auto-fix Dart Code Metrics"
-dir = "mobile"
-hide = true
-run = "dcm fix lib"
-
-# docs deployment
-[tasks."tg:fmt"]
-run = "terragrunt hclfmt"
-description = "Format terragrunt files"
-
-[tasks.tf]
-run = "terragrunt run --all"
-description = "Wrapper for terragrunt run-all"
-dir = "{{cwd}}"
-
-[tasks."tf:fmt"]
-run = "tofu fmt -recursive tf/"
-description = "Format terraform files"
-
-[tasks."tf:init"]
-run = "mise run tf init -- -reconfigure"
-dir = "{{cwd}}"
+dir = "i18n"
+run = "pnpm dlx sort-json *.json"
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
index ae2ec22a71..f62f25558d 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
@@ -143,7 +143,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val mediaUrls = call.argument>("mediaUrls")
if (mediaUrls != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
- moveToTrash(mediaUrls, result)
+ moveToTrash(mediaUrls, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
@@ -155,15 +155,23 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
"restoreFromTrash" -> {
val fileName = call.argument("fileName")
val type = call.argument("type")
+ val mediaId = call.argument("mediaId")
if (fileName != null && type != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
restoreFromTrash(fileName, type, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
- } else {
- result.error("INVALID_NAME", "The file name is not specified.", null)
- }
+ } else
+ if (mediaId != null && type != null) {
+ if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
+ restoreFromTrashById(mediaId, type, result)
+ } else {
+ result.error("PERMISSION_DENIED", "Media permission required", null)
+ }
+ } else {
+ result.error("INVALID_PARAMS", "Required params are not specified.", null)
+ }
}
"requestManageMediaPermission" -> {
@@ -175,6 +183,17 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
}
+ "hasManageMediaPermission" -> {
+ if (hasManageMediaPermission()) {
+ Log.i("Manage storage permission", "Permission already granted")
+ result.success(true)
+ } else {
+ result.success(false)
+ }
+ }
+
+ "manageMediaPermission" -> requestManageMediaPermission(result)
+
else -> result.notImplemented()
}
}
@@ -224,25 +243,47 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
@RequiresApi(Build.VERSION_CODES.R)
- private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) {
- val activity = activityBinding?.activity
- val contentResolver = context?.contentResolver
- if (activity == null || contentResolver == null) {
- result.error("TrashError", "Activity or ContentResolver not available", null)
- return
- }
+ private fun restoreFromTrashById(mediaId: String, type: Int, result: Result) {
+ val id = mediaId.toLongOrNull()
+ if (id == null) {
+ result.error("INVALID_ID", "The file id is not a valid number: $mediaId", null)
+ return
+ }
+ if (!isInTrash(id)) {
+ result.error("TrashNotFound", "Item with id=$id not found in trash", null)
+ return
+ }
- try {
- val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
- pendingResult = result // Store for onActivityResult
- activity.startIntentSenderForResult(
- pendingIntent.intentSender,
- trashRequestCode,
- null, 0, 0, 0
- )
- } catch (e: Exception) {
- Log.e("TrashError", "Error creating or starting trash request", e)
- result.error("TrashError", "Error creating or starting trash request", null)
+ val uri = ContentUris.withAppendedId(contentUriForType(type), id)
+
+ try {
+ Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)")
+ restoreUris(listOf(uri), result)
+ } catch (e: Exception) {
+ Log.w(TAG, "restoreFromTrashById failed", e)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) {
+ val activity = activityBinding?.activity
+ val contentResolver = context?.contentResolver
+ if (activity == null || contentResolver == null) {
+ result.error("TrashError", "Activity or ContentResolver not available", null)
+ return
+ }
+
+ try {
+ val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
+ pendingResult = result // Store for onActivityResult
+ activity.startIntentSenderForResult(
+ pendingIntent.intentSender,
+ trashRequestCode,
+ null, 0, 0, 0
+ )
+ } catch (e: Exception) {
+ Log.e("TrashError", "Error creating or starting trash request", e)
+ result.error("TrashError", "Error creating or starting trash request", null)
}
}
@@ -264,14 +305,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
- // same order as AssetType from dart
- val contentUri = when (type) {
- 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
- 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
- 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
- else -> queryUri
- }
- return ContentUris.withAppendedId(contentUri, id)
+ return ContentUris.withAppendedId(contentUriForType(type), id)
}
}
return null
@@ -315,6 +349,40 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
return false
}
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun isInTrash(id: Long): Boolean {
+ val contentResolver = context?.contentResolver ?: return false
+ val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ val args = Bundle().apply {
+ putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
+ putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
+ putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
+ putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
+ }
+ return contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
+ ?.use { it.moveToFirst() } == true
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun restoreUris(uris: List, result: Result) {
+ if (uris.isEmpty()) {
+ result.error("TrashError", "No URIs to restore", null)
+ return
+ }
+ Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}")
+ toggleTrash(uris, false, result)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private fun contentUriForType(type: Int): Uri =
+ when (type) {
+ // same order as AssetType from dart
+ 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+ else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ }
}
private const val TAG = "BackgroundServicePlugin"
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
index 08ff0e821a..e6cf92f573 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
@@ -305,6 +305,7 @@ interface NativeSyncApi {
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List
fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit)
fun cancelHashing()
+ fun getTrashedAssets(): Map>
companion object {
/** The codec used by NativeSyncApi. */
@@ -483,6 +484,21 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.getTrashedAssets())
+ } catch (exception: Throwable) {
+ MessagesPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
}
}
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
index 5deacc30db..6d2c35d78f 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt
@@ -21,4 +21,9 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
override fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
+
+ override fun getTrashedAssets(): Map> {
+ //Method not supported on this Android version.
+ return emptyMap()
+ }
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
index 052032e143..ca54c9f823 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt
@@ -1,7 +1,9 @@
package app.alextran.immich.sync
+import android.content.ContentResolver
import android.content.Context
import android.os.Build
+import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension
@@ -86,4 +88,29 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
// Unmounted volumes are handled in dart when the album is removed
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
}
+
+ override fun getTrashedAssets(): Map> {
+
+ val result = LinkedHashMap>()
+ val volumes = MediaStore.getExternalVolumeNames(ctx)
+
+ for (volume in volumes) {
+
+ val queryArgs = Bundle().apply {
+ putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION)
+ putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS)
+ putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
+ }
+
+ getCursor(volume, queryArgs).use { cursor ->
+ getAssets(cursor).forEach { res ->
+ if (res is AssetResult.ValidAsset) {
+ result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
+ }
+ }
+ }
+ }
+
+ return result.mapValues { it.value.toList() }
+ }
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
index ca2781f7b4..b1e9dd7d44 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
@@ -4,6 +4,8 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import androidx.core.database.getStringOrNull
@@ -81,6 +83,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
sortOrder,
)
+ protected fun getCursor(
+ volume: String,
+ queryArgs: Bundle
+ ): Cursor? = ctx.contentResolver.query(
+ MediaStore.Files.getContentUri(volume),
+ ASSET_PROJECTION,
+ queryArgs,
+ null
+ )
+
protected fun getAssets(cursor: Cursor?): Sequence {
return sequence {
cursor?.use { c ->
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index 5bcd30589b..6b9ce07465 100644
--- a/mobile/android/fastlane/Fastfile
+++ b/mobile/android/fastlane/Fastfile
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
- "android.injected.version.code" => 3023,
- "android.injected.version.name" => "2.2.0",
+ "android.injected.version.code" => 3026,
+ "android.injected.version.name" => "2.2.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
diff --git a/mobile/drift_schemas/main/drift_schema_v13.json b/mobile/drift_schemas/main/drift_schema_v13.json
new file mode 100644
index 0000000000..e527e8d78a
--- /dev/null
+++ b/mobile/drift_schemas/main/drift_schema_v13.json
@@ -0,0 +1 @@
+{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":8,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":9,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":12,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":14,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":15,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":16,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":17,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":18,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":19,"references":[1,18],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":21,"references":[1,20],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":22,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":23,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":24,"references":[15],"type":"index","data":{"on":15,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":25,"references":[23],"type":"index","data":{"on":23,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":26,"references":[23],"type":"index","data":{"on":23,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}}]}
\ No newline at end of file
diff --git a/mobile/ios/Gemfile b/mobile/ios/Gemfile
index bb94aef518..3b6771ad35 100644
--- a/mobile/ios/Gemfile
+++ b/mobile/ios/Gemfile
@@ -1,4 +1,5 @@
source "https://rubygems.org"
gem "fastlane"
-gem "cocoapods"
\ No newline at end of file
+gem "cocoapods"
+gem "abbrev" # Required for Ruby 3.4+
\ No newline at end of file
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 3f00b6c6aa..599e7990f4 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -32,6 +32,9 @@
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
+ FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
+ FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
+ FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -153,6 +156,13 @@
path = WidgetExtension;
sourceTree = "";
};
+ FEE084F22EC172080045228E /* Schemas */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ );
+ path = Schemas;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -160,6 +170,9 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
+ FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
+ FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -254,6 +267,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
+ FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
@@ -341,6 +355,7 @@
fileSystemSynchronizedGroups = (
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
+ FEE084F22EC172080045228E /* Schemas */,
);
name = Runner;
productName = Runner;
@@ -419,6 +434,10 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
+ FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
+ );
preferredProjectObjectVersion = 77;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -714,7 +733,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -858,7 +877,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -888,7 +907,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -922,7 +941,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -965,7 +984,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1005,7 +1024,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1044,7 +1063,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1088,7 +1107,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1129,7 +1148,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 231;
+ CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1201,6 +1220,43 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/pointfreeco/sqlite-data";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 1.3.0;
+ };
+ };
+ FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/apple/swift-http-structured-headers.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 1.5.0;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ FEE084F72EC172460045228E /* SQLiteData */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
+ productName = SQLiteData;
+ };
+ FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
+ productName = RawStructuredFieldValues;
+ };
+ FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
+ productName = StructuredFieldValues;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000000..432e81234d
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,177 @@
+{
+ "originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
+ "pins" : [
+ {
+ "identity" : "combine-schedulers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/combine-schedulers",
+ "state" : {
+ "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
+ "version" : "1.1.0"
+ }
+ },
+ {
+ "identity" : "grdb.swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/groue/GRDB.swift",
+ "state" : {
+ "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
+ "version" : "7.8.0"
+ }
+ },
+ {
+ "identity" : "opencombine",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/OpenCombine/OpenCombine.git",
+ "state" : {
+ "revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2",
+ "version" : "0.14.0"
+ }
+ },
+ {
+ "identity" : "sqlite-data",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/sqlite-data",
+ "state" : {
+ "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-case-paths",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-case-paths",
+ "state" : {
+ "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
+ "version" : "1.7.2"
+ }
+ },
+ {
+ "identity" : "swift-clocks",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-clocks",
+ "state" : {
+ "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
+ "version" : "1.0.6"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections",
+ "state" : {
+ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
+ "version" : "1.3.2"
+ }
+ },
+ {
+ "identity" : "swift-custom-dump",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-custom-dump",
+ "state" : {
+ "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
+ "version" : "1.3.3"
+ }
+ },
+ {
+ "identity" : "swift-dependencies",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-dependencies",
+ "state" : {
+ "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
+ "version" : "1.10.0"
+ }
+ },
+ {
+ "identity" : "swift-http-structured-headers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-structured-headers.git",
+ "state" : {
+ "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
+ "version" : "1.5.0"
+ }
+ },
+ {
+ "identity" : "swift-identified-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-identified-collections",
+ "state" : {
+ "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-perception",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-perception",
+ "state" : {
+ "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
+ "version" : "2.0.9"
+ }
+ },
+ {
+ "identity" : "swift-sharing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-sharing",
+ "state" : {
+ "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
+ "version" : "2.7.4"
+ }
+ },
+ {
+ "identity" : "swift-snapshot-testing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-snapshot-testing",
+ "state" : {
+ "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
+ "version" : "1.18.7"
+ }
+ },
+ {
+ "identity" : "swift-structured-queries",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-structured-queries",
+ "state" : {
+ "revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9",
+ "version" : "0.25.2"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax",
+ "state" : {
+ "revision" : "4799286537280063c85a32f09884cfbca301b1a1",
+ "version" : "602.0.0"
+ }
+ },
+ {
+ "identity" : "swift-tagged",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-tagged",
+ "state" : {
+ "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
+ "version" : "0.10.0"
+ }
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
+ "state" : {
+ "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
+ "version" : "1.7.0"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000000..ff8a53ff4b
--- /dev/null
+++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,168 @@
+{
+ "originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
+ "pins" : [
+ {
+ "identity" : "combine-schedulers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/combine-schedulers",
+ "state" : {
+ "revision" : "5928286acce13def418ec36d05a001a9641086f2",
+ "version" : "1.0.3"
+ }
+ },
+ {
+ "identity" : "grdb.swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/groue/GRDB.swift",
+ "state" : {
+ "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
+ "version" : "7.8.0"
+ }
+ },
+ {
+ "identity" : "sqlite-data",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/sqlite-data",
+ "state" : {
+ "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-case-paths",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-case-paths",
+ "state" : {
+ "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
+ "version" : "1.7.2"
+ }
+ },
+ {
+ "identity" : "swift-clocks",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-clocks",
+ "state" : {
+ "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
+ "version" : "1.0.6"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections",
+ "state" : {
+ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
+ "version" : "1.3.2"
+ }
+ },
+ {
+ "identity" : "swift-custom-dump",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-custom-dump",
+ "state" : {
+ "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
+ "version" : "1.3.3"
+ }
+ },
+ {
+ "identity" : "swift-dependencies",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-dependencies",
+ "state" : {
+ "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
+ "version" : "1.10.0"
+ }
+ },
+ {
+ "identity" : "swift-http-structured-headers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-structured-headers.git",
+ "state" : {
+ "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
+ "version" : "1.5.0"
+ }
+ },
+ {
+ "identity" : "swift-identified-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-identified-collections",
+ "state" : {
+ "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-perception",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-perception",
+ "state" : {
+ "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
+ "version" : "2.0.9"
+ }
+ },
+ {
+ "identity" : "swift-sharing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-sharing",
+ "state" : {
+ "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
+ "version" : "2.7.4"
+ }
+ },
+ {
+ "identity" : "swift-snapshot-testing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-snapshot-testing",
+ "state" : {
+ "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
+ "version" : "1.18.7"
+ }
+ },
+ {
+ "identity" : "swift-structured-queries",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-structured-queries",
+ "state" : {
+ "revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
+ "version" : "0.25.0"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax",
+ "state" : {
+ "revision" : "4799286537280063c85a32f09884cfbca301b1a1",
+ "version" : "602.0.0"
+ }
+ },
+ {
+ "identity" : "swift-tagged",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-tagged",
+ "state" : {
+ "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
+ "version" : "0.10.0"
+ }
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
+ "state" : {
+ "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
+ "version" : "1.7.0"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 1dc55468da..7a3a9261ae 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -80,7 +80,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.1.0
+ 2.2.1
CFBundleSignature
????
CFBundleURLTypes
@@ -107,7 +107,7 @@
CFBundleVersion
- 231
+ 233
FLTEnableImpeller
ITSAppUsesNonExemptEncryption
diff --git a/mobile/ios/Runner/Schemas/Constants.swift b/mobile/ios/Runner/Schemas/Constants.swift
new file mode 100644
index 0000000000..a4b0f701a1
--- /dev/null
+++ b/mobile/ios/Runner/Schemas/Constants.swift
@@ -0,0 +1,177 @@
+import SQLiteData
+
+struct Endpoint: Codable {
+ let url: URL
+ let status: Status
+
+ enum Status: String, Codable {
+ case loading, valid, error, unknown
+ }
+}
+
+enum StoreKey: Int, CaseIterable, QueryBindable {
+ // MARK: - Int
+ case _version = 0
+ static let version = Typed(rawValue: ._version)
+ case _deviceIdHash = 3
+ static let deviceIdHash = Typed(rawValue: ._deviceIdHash)
+ case _backupTriggerDelay = 8
+ static let backupTriggerDelay = Typed(rawValue: ._backupTriggerDelay)
+ case _tilesPerRow = 103
+ static let tilesPerRow = Typed(rawValue: ._tilesPerRow)
+ case _groupAssetsBy = 105
+ static let groupAssetsBy = Typed(rawValue: ._groupAssetsBy)
+ case _uploadErrorNotificationGracePeriod = 106
+ static let uploadErrorNotificationGracePeriod = Typed(rawValue: ._uploadErrorNotificationGracePeriod)
+ case _thumbnailCacheSize = 110
+ static let thumbnailCacheSize = Typed(rawValue: ._thumbnailCacheSize)
+ case _imageCacheSize = 111
+ static let imageCacheSize = Typed(rawValue: ._imageCacheSize)
+ case _albumThumbnailCacheSize = 112
+ static let albumThumbnailCacheSize = Typed(rawValue: ._albumThumbnailCacheSize)
+ case _selectedAlbumSortOrder = 113
+ static let selectedAlbumSortOrder = Typed(rawValue: ._selectedAlbumSortOrder)
+ case _logLevel = 115
+ static let logLevel = Typed(rawValue: ._logLevel)
+ case _mapRelativeDate = 119
+ static let mapRelativeDate = Typed(rawValue: ._mapRelativeDate)
+ case _mapThemeMode = 124
+ static let mapThemeMode = Typed(rawValue: ._mapThemeMode)
+
+ // MARK: - String
+ case _assetETag = 1
+ static let assetETag = Typed(rawValue: ._assetETag)
+ case _currentUser = 2
+ static let currentUser = Typed(rawValue: ._currentUser)
+ case _deviceId = 4
+ static let deviceId = Typed(rawValue: ._deviceId)
+ case _accessToken = 11
+ static let accessToken = Typed(rawValue: ._accessToken)
+ case _serverEndpoint = 12
+ static let serverEndpoint = Typed(rawValue: ._serverEndpoint)
+ case _sslClientCertData = 15
+ static let sslClientCertData = Typed(rawValue: ._sslClientCertData)
+ case _sslClientPasswd = 16
+ static let sslClientPasswd = Typed(rawValue: ._sslClientPasswd)
+ case _themeMode = 102
+ static let themeMode = Typed(rawValue: ._themeMode)
+ case _customHeaders = 127
+ static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders)
+ case _primaryColor = 128
+ static let primaryColor = Typed(rawValue: ._primaryColor)
+ case _preferredWifiName = 133
+ static let preferredWifiName = Typed(rawValue: ._preferredWifiName)
+
+ // MARK: - Endpoint
+ case _externalEndpointList = 135
+ static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
+
+ // MARK: - URL
+ case _localEndpoint = 134
+ static let localEndpoint = Typed(rawValue: ._localEndpoint)
+ case _serverUrl = 10
+ static let serverUrl = Typed(rawValue: ._serverUrl)
+
+ // MARK: - Date
+ case _backupFailedSince = 5
+ static let backupFailedSince = Typed(rawValue: ._backupFailedSince)
+
+ // MARK: - Bool
+ case _backupRequireWifi = 6
+ static let backupRequireWifi = Typed(rawValue: ._backupRequireWifi)
+ case _backupRequireCharging = 7
+ static let backupRequireCharging = Typed(rawValue: ._backupRequireCharging)
+ case _autoBackup = 13
+ static let autoBackup = Typed(rawValue: ._autoBackup)
+ case _backgroundBackup = 14
+ static let backgroundBackup = Typed(rawValue: ._backgroundBackup)
+ case _loadPreview = 100
+ static let loadPreview = Typed(rawValue: ._loadPreview)
+ case _loadOriginal = 101
+ static let loadOriginal = Typed(rawValue: ._loadOriginal)
+ case _dynamicLayout = 104
+ static let dynamicLayout = Typed(rawValue: ._dynamicLayout)
+ case _backgroundBackupTotalProgress = 107
+ static let backgroundBackupTotalProgress = Typed(rawValue: ._backgroundBackupTotalProgress)
+ case _backgroundBackupSingleProgress = 108
+ static let backgroundBackupSingleProgress = Typed(rawValue: ._backgroundBackupSingleProgress)
+ case _storageIndicator = 109
+ static let storageIndicator = Typed(rawValue: ._storageIndicator)
+ case _advancedTroubleshooting = 114
+ static let advancedTroubleshooting = Typed(rawValue: ._advancedTroubleshooting)
+ case _preferRemoteImage = 116
+ static let preferRemoteImage = Typed(rawValue: ._preferRemoteImage)
+ case _loopVideo = 117
+ static let loopVideo = Typed(rawValue: ._loopVideo)
+ case _mapShowFavoriteOnly = 118
+ static let mapShowFavoriteOnly = Typed(rawValue: ._mapShowFavoriteOnly)
+ case _selfSignedCert = 120
+ static let selfSignedCert = Typed(rawValue: ._selfSignedCert)
+ case _mapIncludeArchived = 121
+ static let mapIncludeArchived = Typed(rawValue: ._mapIncludeArchived)
+ case _ignoreIcloudAssets = 122
+ static let ignoreIcloudAssets = Typed(rawValue: ._ignoreIcloudAssets)
+ case _selectedAlbumSortReverse = 123
+ static let selectedAlbumSortReverse = Typed(rawValue: ._selectedAlbumSortReverse)
+ case _mapwithPartners = 125
+ static let mapwithPartners = Typed(rawValue: ._mapwithPartners)
+ case _enableHapticFeedback = 126
+ static let enableHapticFeedback = Typed(rawValue: ._enableHapticFeedback)
+ case _dynamicTheme = 129
+ static let dynamicTheme = Typed(rawValue: ._dynamicTheme)
+ case _colorfulInterface = 130
+ static let colorfulInterface = Typed(rawValue: ._colorfulInterface)
+ case _syncAlbums = 131
+ static let syncAlbums = Typed(rawValue: ._syncAlbums)
+ case _autoEndpointSwitching = 132
+ static let autoEndpointSwitching = Typed(rawValue: ._autoEndpointSwitching)
+ case _loadOriginalVideo = 136
+ static let loadOriginalVideo = Typed(rawValue: ._loadOriginalVideo)
+ case _manageLocalMediaAndroid = 137
+ static let manageLocalMediaAndroid = Typed(rawValue: ._manageLocalMediaAndroid)
+ case _readonlyModeEnabled = 138
+ static let readonlyModeEnabled = Typed(rawValue: ._readonlyModeEnabled)
+ case _autoPlayVideo = 139
+ static let autoPlayVideo = Typed(rawValue: ._autoPlayVideo)
+ case _photoManagerCustomFilter = 1000
+ static let photoManagerCustomFilter = Typed(rawValue: ._photoManagerCustomFilter)
+ case _betaPromptShown = 1001
+ static let betaPromptShown = Typed(rawValue: ._betaPromptShown)
+ case _betaTimeline = 1002
+ static let betaTimeline = Typed(rawValue: ._betaTimeline)
+ case _enableBackup = 1003
+ static let enableBackup = Typed(rawValue: ._enableBackup)
+ case _useWifiForUploadVideos = 1004
+ static let useWifiForUploadVideos = Typed(rawValue: ._useWifiForUploadVideos)
+ case _useWifiForUploadPhotos = 1005
+ static let useWifiForUploadPhotos = Typed(rawValue: ._useWifiForUploadPhotos)
+ case _needBetaMigration = 1006
+ static let needBetaMigration = Typed(rawValue: ._needBetaMigration)
+ case _shouldResetSync = 1007
+ static let shouldResetSync = Typed(rawValue: ._shouldResetSync)
+
+ struct Typed: RawRepresentable {
+ let rawValue: StoreKey
+
+ @_transparent
+ init(rawValue value: StoreKey) {
+ self.rawValue = value
+ }
+ }
+}
+
+enum BackupSelection: Int, QueryBindable {
+ case selected, none, excluded
+}
+
+enum AvatarColor: Int, QueryBindable {
+ case primary, pink, red, yellow, blue, green, purple, orange, gray, amber
+}
+
+enum AlbumUserRole: Int, QueryBindable {
+ case editor, viewer
+}
+
+enum MemoryType: Int, QueryBindable {
+ case onThisDay
+}
diff --git a/mobile/ios/Runner/Schemas/Store.swift b/mobile/ios/Runner/Schemas/Store.swift
new file mode 100644
index 0000000000..ee5280b6c0
--- /dev/null
+++ b/mobile/ios/Runner/Schemas/Store.swift
@@ -0,0 +1,146 @@
+import SQLiteData
+
+enum StoreError: Error {
+ case invalidJSON(String)
+ case invalidURL(String)
+ case encodingFailed
+}
+
+protocol StoreConvertible {
+ associatedtype StorageType
+ static func fromValue(_ value: StorageType) throws(StoreError) -> Self
+ static func toValue(_ value: Self) throws(StoreError) -> StorageType
+}
+
+extension Int: StoreConvertible {
+ static func fromValue(_ value: Int) -> Int { value }
+ static func toValue(_ value: Int) -> Int { value }
+}
+
+extension Bool: StoreConvertible {
+ static func fromValue(_ value: Int) -> Bool { value == 1 }
+ static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
+}
+
+extension Date: StoreConvertible {
+ static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
+ static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
+}
+
+extension String: StoreConvertible {
+ static func fromValue(_ value: String) -> String { value }
+ static func toValue(_ value: String) -> String { value }
+}
+
+extension URL: StoreConvertible {
+ static func fromValue(_ value: String) throws(StoreError) -> URL {
+ guard let url = URL(string: value) else {
+ throw StoreError.invalidURL(value)
+ }
+ return url
+ }
+ static func toValue(_ value: URL) -> String { value.absoluteString }
+}
+
+extension StoreConvertible where Self: Codable, StorageType == String {
+ static var jsonDecoder: JSONDecoder { JSONDecoder() }
+ static var jsonEncoder: JSONEncoder { JSONEncoder() }
+
+ static func fromValue(_ value: String) throws(StoreError) -> Self {
+ do {
+ return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
+ } catch {
+ throw StoreError.invalidJSON(value)
+ }
+ }
+
+ static func toValue(_ value: Self) throws(StoreError) -> String {
+ let encoded: Data
+ do {
+ encoded = try jsonEncoder.encode(value)
+ } catch {
+ throw StoreError.encodingFailed
+ }
+
+ guard let string = String(data: encoded, encoding: .utf8) else {
+ throw StoreError.encodingFailed
+ }
+ return string
+ }
+}
+
+extension Array: StoreConvertible where Element: Codable {
+ typealias StorageType = String
+}
+
+extension Dictionary: StoreConvertible where Key == String, Value: Codable {
+ typealias StorageType = String
+}
+
+class StoreRepository {
+ private let db: DatabasePool
+
+ init(db: DatabasePool) {
+ self.db = db
+ }
+
+ func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == Int {
+ let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
+ if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
+ return try T.fromValue(value)
+ }
+ return nil
+ }
+
+ func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == String {
+ let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
+ if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
+ return try T.fromValue(value)
+ }
+ return nil
+ }
+
+ func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == Int {
+ let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
+ if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
+ return try T.fromValue(value)
+ }
+ return nil
+ }
+
+ func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == String {
+ let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
+ if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
+ return try T.fromValue(value)
+ }
+ return nil
+ }
+
+ func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == Int {
+ let value = try T.toValue(value)
+ try db.write { conn in
+ try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
+ }
+ }
+
+ func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == String {
+ let value = try T.toValue(value)
+ try db.write { conn in
+ try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
+ }
+ }
+
+ func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == Int {
+ let value = try T.toValue(value)
+ try await db.write { conn in
+ try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
+ }
+ }
+
+ func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == String {
+ let value = try T.toValue(value)
+ try await db.write { conn in
+ try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
+ }
+ }
+}
diff --git a/mobile/ios/Runner/Schemas/Tables.swift b/mobile/ios/Runner/Schemas/Tables.swift
new file mode 100644
index 0000000000..c256b0d0ed
--- /dev/null
+++ b/mobile/ios/Runner/Schemas/Tables.swift
@@ -0,0 +1,237 @@
+import GRDB
+import SQLiteData
+
+@Table("asset_face_entity")
+struct AssetFace {
+ let id: String
+ let assetId: String
+ let personId: String?
+ let imageWidth: Int
+ let imageHeight: Int
+ let boundingBoxX1: Int
+ let boundingBoxY1: Int
+ let boundingBoxX2: Int
+ let boundingBoxY2: Int
+ let sourceType: String
+}
+
+@Table("auth_user_entity")
+struct AuthUser {
+ let id: String
+ let name: String
+ let email: String
+ let isAdmin: Bool
+ let hasProfileImage: Bool
+ let profileChangedAt: Date
+ let avatarColor: AvatarColor
+ let quotaSizeInBytes: Int
+ let quotaUsageInBytes: Int
+ let pinCode: String?
+}
+
+@Table("local_album_entity")
+struct LocalAlbum {
+ let id: String
+ let backupSelection: BackupSelection
+ let linkedRemoteAlbumId: String?
+ let marker_: Bool?
+ let name: String
+ let isIosSharedAlbum: Bool
+ let updatedAt: Date
+}
+
+@Table("local_album_asset_entity")
+struct LocalAlbumAsset {
+ let id: ID
+ let marker_: String?
+
+ @Selection
+ struct ID {
+ let assetId: String
+ let albumId: String
+ }
+}
+
+@Table("local_asset_entity")
+struct LocalAsset {
+ let id: String
+ let checksum: String?
+ let createdAt: Date
+ let durationInSeconds: Int?
+ let height: Int?
+ let isFavorite: Bool
+ let name: String
+ let orientation: String
+ let type: Int
+ let updatedAt: Date
+ let width: Int?
+}
+
+@Table("memory_asset_entity")
+struct MemoryAsset {
+ let id: ID
+
+ @Selection
+ struct ID {
+ let assetId: String
+ let albumId: String
+ }
+}
+
+@Table("memory_entity")
+struct Memory {
+ let id: String
+ let createdAt: Date
+ let updatedAt: Date
+ let deletedAt: Date?
+ let ownerId: String
+ let type: MemoryType
+ let data: String
+ let isSaved: Bool
+ let memoryAt: Date
+ let seenAt: Date?
+ let showAt: Date?
+ let hideAt: Date?
+}
+
+@Table("partner_entity")
+struct Partner {
+ let id: ID
+ let inTimeline: Bool
+
+ @Selection
+ struct ID {
+ let sharedById: String
+ let sharedWithId: String
+ }
+}
+
+@Table("person_entity")
+struct Person {
+ let id: String
+ let createdAt: Date
+ let updatedAt: Date
+ let ownerId: String
+ let name: String
+ let faceAssetId: String?
+ let isFavorite: Bool
+ let isHidden: Bool
+ let color: String?
+ let birthDate: Date?
+}
+
+@Table("remote_album_entity")
+struct RemoteAlbum {
+ let id: String
+ let createdAt: Date
+ let description: String?
+ let isActivityEnabled: Bool
+ let name: String
+ let order: Int
+ let ownerId: String
+ let thumbnailAssetId: String?
+ let updatedAt: Date
+}
+
+@Table("remote_album_asset_entity")
+struct RemoteAlbumAsset {
+ let id: ID
+
+ @Selection
+ struct ID {
+ let assetId: String
+ let albumId: String
+ }
+}
+
+@Table("remote_album_user_entity")
+struct RemoteAlbumUser {
+ let id: ID
+ let role: AlbumUserRole
+
+ @Selection
+ struct ID {
+ let albumId: String
+ let userId: String
+ }
+}
+
+@Table("remote_asset_entity")
+struct RemoteAsset {
+ let id: String
+ let checksum: String?
+ let deletedAt: Date?
+ let isFavorite: Int
+ let libraryId: String?
+ let livePhotoVideoId: String?
+ let localDateTime: Date?
+ let orientation: String
+ let ownerId: String
+ let stackId: String?
+ let visibility: Int
+}
+
+@Table("remote_exif_entity")
+struct RemoteExif {
+ @Column(primaryKey: true)
+ let assetId: String
+ let city: String?
+ let state: String?
+ let country: String?
+ let dateTimeOriginal: Date?
+ let description: String?
+ let height: Int?
+ let width: Int?
+ let exposureTime: String?
+ let fNumber: Double?
+ let fileSize: Int?
+ let focalLength: Double?
+ let latitude: Double?
+ let longitude: Double?
+ let iso: Int?
+ let make: String?
+ let model: String?
+ let lens: String?
+ let orientation: String?
+ let timeZone: String?
+ let rating: Int?
+ let projectionType: String?
+}
+
+@Table("stack_entity")
+struct Stack {
+ let id: String
+ let createdAt: Date
+ let updatedAt: Date
+ let ownerId: String
+ let primaryAssetId: String
+}
+
+@Table("store_entity")
+struct Store {
+ let id: StoreKey
+ let stringValue: String?
+ let intValue: Int?
+}
+
+@Table("user_entity")
+struct User {
+ let id: String
+ let name: String
+ let email: String
+ let hasProfileImage: Bool
+ let profileChangedAt: Date
+ let avatarColor: AvatarColor
+}
+
+@Table("user_metadata_entity")
+struct UserMetadata {
+ let id: ID
+ let value: Data
+
+ @Selection
+ struct ID {
+ let userId: String
+ let key: Date
+ }
+}
diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift
index 6bcafb9215..bbe18e7375 100644
--- a/mobile/ios/Runner/Sync/Messages.g.swift
+++ b/mobile/ios/Runner/Sync/Messages.g.swift
@@ -364,6 +364,7 @@ protocol NativeSyncApi {
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
+ func getTrashedAssets() throws -> [String: [PlatformAsset]]
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -532,5 +533,20 @@ class NativeSyncApiSetup {
} else {
cancelHashingChannel.setMessageHandler(nil)
}
+ let getTrashedAssetsChannel = taskQueue == nil
+ ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+ if let api = api {
+ getTrashedAssetsChannel.setMessageHandler { _, reply in
+ do {
+ let result = try api.getTrashedAssets()
+ reply(wrapResult(result))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } else {
+ getTrashedAssetsChannel.setMessageHandler(nil)
+ }
}
}
diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift
index 75981fb7ea..03493f57ca 100644
--- a/mobile/ios/Runner/Sync/MessagesImpl.swift
+++ b/mobile/ios/Runner/Sync/MessagesImpl.swift
@@ -3,15 +3,15 @@ import CryptoKit
struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset
-
+
init(with asset: PlatformAsset) {
self.asset = asset
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(self.asset.id)
}
-
+
static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
return lhs.asset.id == rhs.asset.id
}
@@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable {
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
static let name = "NativeSyncApi"
-
+
static func register(with registrar: any FlutterPluginRegistrar) {
let instance = NativeSyncApiImpl()
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
registrar.publish(instance)
}
-
+
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
super.detachFromEngine()
}
-
+
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219
-
+
private var hashTask: Task?
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
-
-
+
+
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
}
-
+
@available(iOS 16, *)
private func getChangeToken() -> PHPersistentChangeToken? {
guard let data = defaults.data(forKey: changeTokenKey) else {
@@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
}
-
+
@available(iOS 16, *)
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
@@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
defaults.set(data, forKey: changeTokenKey)
}
-
+
func clearSyncCheckpoint() -> Void {
defaults.removeObject(forKey: changeTokenKey)
}
-
+
func checkpointSync() {
guard #available(iOS 16, *) else {
return
}
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
}
-
+
func shouldFullSync() -> Bool {
guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
@@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
return true
}
-
+
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
// Cannot fetch persistent changes
return true
}
-
+
return false
}
-
+
func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
-
+
albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0.. SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
-
+
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
}
-
+
guard let storedToken = getChangeToken() else {
// No token exists, definitely need a full sync
print("MediaManager::getMediaChanges: No token found")
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
}
-
+
let currentToken = PHPhotoLibrary.shared().currentChangeToken
if storedToken == currentToken {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
}
-
+
do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
-
+
var updatedAssets: Set = []
var deletedAssets: Set = []
-
+
for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
-
+
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
-
+
if (updated.isEmpty) { continue }
-
+
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..) -> [String: [String]] {
guard !assets.isEmpty else {
return [:]
}
-
+
var albumAssets: [String: [String]] = [:]
-
+
for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in
@@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return albumAssets
}
-
+
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
}
-
+
var ids: [String] = []
let options = PHFetchOptions()
options.includeHiddenAssets = false
@@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return ids
}
-
+
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return 0
}
-
+
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
@@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let assets = getAssetsFromAlbum(in: album, options: options)
return Int64(assets.count)
}
-
+
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
}
-
+
let options = PHFetchOptions()
options.includeHiddenAssets = false
if(updatedTimeCond != nil) {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
}
-
+
let result = getAssetsFromAlbum(in: album, options: options)
if(result.count == 0) {
return []
}
-
+
var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, _) in
assets.append(asset.toPlatformAsset())
}
return assets
}
-
+
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
if let prevTask = hashTask {
prevTask.cancel()
@@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
missingAssetIds.remove(asset.localIdentifier)
assets.append(asset)
}
-
+
if Task.isCancelled {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
}
-
+
await withTaskGroup(of: HashResult?.self) { taskGroup in
var results = [HashResult]()
results.reserveCapacity(assets.count)
@@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
}
}
-
+
for await result in taskGroup {
guard let result = result else {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
}
results.append(result)
}
-
+
for missing in missingAssetIds {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
}
-
+
return self?.completeWhenActive(for: completion, with: .success(results))
}
}
}
-
+
func cancelHashing() {
hashTask?.cancel()
hashTask = nil
}
-
+
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
@@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
if Task.isCancelled {
return nil
}
-
+
guard let resource = asset.getResource() else {
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
}
-
+
if Task.isCancelled {
return nil
}
-
+
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
-
+
return await withCheckedContinuation { continuation in
var hasher = Insecure.SHA1()
-
+
requestRef.id = PHAssetResourceManager.default().requestData(
for: resource,
options: options,
@@ -377,7 +377,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
PHAssetResourceManager.default().cancelDataRequest(requestId)
})
}
-
+
+ func getTrashedAssets() throws -> [String: [PlatformAsset]] {
+ throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
+ }
+
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult {
// Ensure to actually getting all assets for the Recents album
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index 260b729579..c3dfea5f66 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -16,42 +16,92 @@
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(
+ # Constants
+ TEAM_ID = "2F67MQ8R79"
+ CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
+ BASE_BUNDLE_ID = "app.alextran.immich"
+
+ # Helper method to get App Store Connect API key
+ def get_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"
+ key_filepath: "#{Dir.home}/.appstoreconnect/private_keys/AuthKey_#{ENV['APP_STORE_CONNECT_API_KEY_ID']}.p8",
+ duration: 1200,
+ in_house: false
)
+ end
+
+ # Helper method to get version from pubspec.yaml
+def get_version_from_pubspec
+ require 'yaml'
+
+ pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml")
+ pubspec = YAML.load_file(pubspec_path)
+
+ version_string = pubspec['version']
+ version_string ? version_string.split('+').first : nil
+end
+
+ # Helper method to configure code signing for all targets
+ def configure_code_signing(bundle_id_suffix: "")
+ bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
- # 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
+ # Runner (main app)
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
- team_id: ENV["FASTLANE_TEAM_ID"],
- profile_name: "app.alextran.immich AppStore"
+ team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
+ code_sign_identity: CODE_SIGN_IDENTITY,
+ bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
+ profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
+ targets: ["Runner"]
)
+ # ShareExtension
+ update_code_signing_settings(
+ use_automatic_signing: false,
+ path: "./Runner.xcodeproj",
+ team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
+ code_sign_identity: CODE_SIGN_IDENTITY,
+ bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
+ profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
+ targets: ["ShareExtension"]
+ )
+
+ # WidgetExtension
+ update_code_signing_settings(
+ use_automatic_signing: false,
+ path: "./Runner.xcodeproj",
+ team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
+ code_sign_identity: CODE_SIGN_IDENTITY,
+ bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
+ profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
+ targets: ["WidgetExtension"]
+ )
+ end
+
+ # Helper method to build and upload to TestFlight
+ def build_and_upload(
+ api_key:,
+ bundle_id_suffix: "",
+ configuration: "Release",
+ distribute_external: true,
+ version_number: nil
+ )
+ bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
+ app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
+
+ # Set version number if provided
+ if version_number
+ increment_version_number(version_number: version_number)
+ end
+
# Increment build number
increment_build_number(
build_number: latest_testflight_build_number(
api_key: api_key,
- app_identifier: "app.alextran.immich"
+ app_identifier: app_identifier
) + 1,
xcodeproj: "./Runner.xcodeproj"
)
@@ -60,35 +110,101 @@ platform :ios do
build_app(
scheme: "Runner",
workspace: "Runner.xcworkspace",
+ configuration: configuration,
export_method: "app-store",
+ xcargs: "CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
- "app.alextran.immich" => "app.alextran.immich AppStore"
- }
+ "#{app_identifier}" => "#{app_identifier} AppStore",
+ "#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
+ "#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
+ },
+ signingStyle: "manual",
+ signingCertificate: CODE_SIGN_IDENTITY
}
)
# Upload to TestFlight
upload_to_testflight(
api_key: api_key,
- skip_waiting_for_build_processing: true
+ skip_waiting_for_build_processing: true,
+ distribute_external: distribute_external
+ )
+ end
+
+ desc "iOS Development Build to TestFlight (requires separate bundle ID)"
+ lane :gha_testflight_dev do
+ api_key = get_api_key
+
+ # Install development provisioning profiles
+ install_provisioning_profile(path: "profile_dev.mobileprovision")
+ install_provisioning_profile(path: "profile_dev_share.mobileprovision")
+ install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
+
+ # Configure code signing for dev bundle IDs
+ configure_code_signing(bundle_id_suffix: "development")
+
+ # Build and upload
+ build_and_upload(
+ api_key: api_key,
+ bundle_id_suffix: "development",
+ configuration: "Profile",
+ distribute_external: false
)
end
- desc "iOS Release"
- lane :release do
+ desc "iOS Release to TestFlight"
+ lane :gha_release_prod do
+ api_key = get_api_key
+
+ # Install provisioning profiles
+ install_provisioning_profile(path: "profile.mobileprovision")
+ install_provisioning_profile(path: "profile_share.mobileprovision")
+ install_provisioning_profile(path: "profile_widget.mobileprovision")
+
+
+ # Configure code signing for production bundle IDs
+ configure_code_signing
+
+ # Build and upload with version number
+ build_and_upload(
+ api_key: api_key,
+ version_number: get_version_from_pubspec,
+ distribute_external: false,
+ )
+ end
+
+ desc "iOS Manual Release"
+ lane :release_manual do
enable_automatic_code_signing(
path: "./Runner.xcodeproj",
+ targets: ["Runner", "ShareExtension", "WidgetExtension"]
)
+
increment_version_number(
- version_number: "2.2.0"
+ version_number: get_version_from_pubspec
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
)
- build_app(scheme: "Runner",
- workspace: "Runner.xcworkspace",
- xcargs: "-allowProvisioningUpdates")
+
+ # Build archive with automatic signing
+ gym(
+ scheme: "Runner",
+ workspace: "Runner.xcworkspace",
+ configuration: "Release",
+ export_method: "app-store",
+ skip_package_ipa: false,
+ xcargs: "-allowProvisioningUpdates",
+ export_options: {
+ method: "app-store",
+ signingStyle: "automatic",
+ uploadBitcode: false,
+ uploadSymbols: true,
+ compileBitcode: false
+ }
+ )
+
upload_to_testflight(
skip_waiting_for_build_processing: true
)
diff --git a/mobile/ios/fastlane/README.md b/mobile/ios/fastlane/README.md
index 2999821730..5fc8101b3a 100644
--- a/mobile/ios/fastlane/README.md
+++ b/mobile/ios/fastlane/README.md
@@ -15,13 +15,29 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## iOS
-### ios release
+### ios gha_testflight_dev
```sh
-[bundle exec] fastlane ios release
+[bundle exec] fastlane ios gha_testflight_dev
```
-iOS Release
+iOS Development Build to TestFlight (requires separate bundle ID)
+
+### ios gha_release_prod
+
+```sh
+[bundle exec] fastlane ios gha_release_prod
+```
+
+iOS Release to TestFlight
+
+### ios release_manual
+
+```sh
+[bundle exec] fastlane ios release_manual
+```
+
+iOS Manual Release
----
diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart
index 10f4e88f0f..8d4636bbe1 100644
--- a/mobile/lib/constants/constants.dart
+++ b/mobile/lib/constants/constants.dart
@@ -53,3 +53,11 @@ const int kMinMonthsToEnableScrubberSnap = 12;
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id6449244941";
const String kImmichPlayStoreLink = "https://play.google.com/store/apps/details?id=app.alextran.immich";
const String kImmichLatestRelease = "https://github.com/immich-app/immich/releases/latest";
+
+const int kPhotoTabIndex = 0;
+const int kSearchTabIndex = 1;
+const int kAlbumTabIndex = 2;
+const int kLibraryTabIndex = 3;
+
+// Workaround for SQLite's variable limit (SQLITE_MAX_VARIABLE_NUMBER = 32766)
+const int kDriftMaxChunk = 32000;
diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart
index 5c228ba67c..28c87293f9 100644
--- a/mobile/lib/domain/services/background_worker.service.dart
+++ b/mobile/lib/domain/services/background_worker.service.dart
@@ -239,7 +239,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
return _ref
?.read(uploadServiceProvider)
- .startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken);
+ .startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
},
(error, stack) {
dPrint(() => "Error in backup zone $error, $stack");
diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart
index 90f29b8bc1..5e81643fc5 100644
--- a/mobile/lib/domain/services/hash.service.dart
+++ b/mobile/lib/domain/services/hash.service.dart
@@ -2,8 +2,10 @@ import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
+import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
@@ -13,6 +15,7 @@ class HashService {
final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
+ final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -20,11 +23,13 @@ class HashService {
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
+ required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
+ _trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit;
@@ -49,6 +54,14 @@ class HashService {
await _hashAssets(album, assetsToHash);
}
}
+ if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
+ final backupAlbumIds = localAlbums.map((e) => e.id);
+ final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
+ if (trashedToHash.isNotEmpty) {
+ final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
+ await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
+ }
+ }
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
@@ -65,7 +78,7 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
- Future _hashAssets(LocalAlbum album, List assetsToHash) async {
+ Future _hashAssets(LocalAlbum album, List assetsToHash, {bool isTrashed = false}) async {
final toHash = {};
for (final asset in assetsToHash) {
@@ -76,16 +89,16 @@ class HashService {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
- await _processBatch(album, toHash);
+ await _processBatch(album, toHash, isTrashed);
toHash.clear();
}
}
- await _processBatch(album, toHash);
+ await _processBatch(album, toHash, isTrashed);
}
/// Processes a batch of assets.
- Future _processBatch(LocalAlbum album, Map toHash) async {
+ Future _processBatch(LocalAlbum album, Map toHash, bool isTrashed) async {
if (toHash.isEmpty) {
return;
}
@@ -120,7 +133,10 @@ class HashService {
}
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
-
- await _localAssetRepository.updateHashes(hashed);
+ if (isTrashed) {
+ await _trashedLocalAssetRepository.updateHashes(hashed);
+ } else {
+ await _localAssetRepository.updateHashes(hashed);
+ }
}
}
diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart
index 94a8a19e73..5cbae9c5a1 100644
--- a/mobile/lib/domain/services/local_sync.service.dart
+++ b/mobile/lib/domain/services/local_sync.service.dart
@@ -4,9 +4,14 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/models/store.model.dart';
+import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
+import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
+import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
+import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
@@ -14,15 +19,34 @@ import 'package:logging/logging.dart';
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi;
+ final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
+ final LocalFilesManagerRepository _localFilesManager;
+ final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
- LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi})
- : _localAlbumRepository = localAlbumRepository,
- _nativeSyncApi = nativeSyncApi;
+ LocalSyncService({
+ required DriftLocalAlbumRepository localAlbumRepository,
+ required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
+ required LocalFilesManagerRepository localFilesManager,
+ required StorageRepository storageRepository,
+ required NativeSyncApi nativeSyncApi,
+ }) : _localAlbumRepository = localAlbumRepository,
+ _trashedLocalAssetRepository = trashedLocalAssetRepository,
+ _localFilesManager = localFilesManager,
+ _storageRepository = storageRepository,
+ _nativeSyncApi = nativeSyncApi;
Future sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
+ if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
+ final hasPermission = await _localFilesManager.hasManageMediaPermission();
+ if (hasPermission) {
+ await _syncTrashedAssets();
+ } else {
+ _log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
+ }
+ }
if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync();
@@ -69,7 +93,6 @@ class LocalSyncService {
await updateAlbum(dbAlbum, album);
}
}
-
await _nativeSyncApi.checkpointSync();
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
@@ -273,6 +296,48 @@ class LocalSyncService {
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
}
+
+ Future _syncTrashedAssets() async {
+ final trashedAssetMap = await _nativeSyncApi.getTrashedAssets();
+ await processTrashedAssets(trashedAssetMap);
+ }
+
+ @visibleForTesting
+ Future processTrashedAssets(Map> trashedAssetMap) async {
+ if (trashedAssetMap.isEmpty) {
+ _log.info("syncTrashedAssets, No trashed assets found");
+ }
+ final trashedAssets = trashedAssetMap.cast>().entries.expand(
+ (entry) => entry.value.cast().toTrashedAssets(entry.key),
+ );
+
+ _log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
+ await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
+
+ final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
+ if (assetsToRestore.isNotEmpty) {
+ final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
+ await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
+ } else {
+ _log.info("syncTrashedAssets, No remote assets found for restoration");
+ }
+
+ final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
+ if (localAssetsToTrash.isNotEmpty) {
+ final mediaUrls = await Future.wait(
+ localAssetsToTrash.values
+ .expand((e) => e)
+ .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
+ );
+ _log.info("Moving to trash ${mediaUrls.join(", ")} assets");
+ final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
+ if (result) {
+ await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
+ }
+ } else {
+ _log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
+ }
+ }
}
extension on Iterable {
@@ -290,20 +355,26 @@ extension on Iterable {
extension on Iterable {
List toLocalAssets() {
- return map(
- (e) => LocalAsset(
- id: e.id,
- name: e.name,
- checksum: null,
- type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
- createdAt: tryFromSecondsSinceEpoch(e.createdAt, isUtc: true) ?? DateTime.timestamp(),
- updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
- width: e.width,
- height: e.height,
- durationInSeconds: e.durationInSeconds,
- orientation: e.orientation,
- isFavorite: e.isFavorite,
- ),
- ).toList();
+ return map((e) => e.toLocalAsset()).toList();
+ }
+
+ Iterable toTrashedAssets(String albumId) {
+ return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
}
}
+
+extension on PlatformAsset {
+ LocalAsset toLocalAsset() => LocalAsset(
+ id: id,
+ name: name,
+ checksum: null,
+ type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
+ createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
+ updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
+ width: width,
+ height: height,
+ durationInSeconds: durationInSeconds,
+ isFavorite: isFavorite,
+ orientation: orientation,
+ );
+}
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index 5ed11598dc..2ff0f18fcf 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -1,8 +1,15 @@
import 'dart:async';
+import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
+import 'package:immich_mobile/entities/store.entity.dart';
+import 'package:immich_mobile/extensions/platform_extensions.dart';
+import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
+import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
+import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
+import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -11,14 +18,26 @@ class SyncStreamService {
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
+ final DriftLocalAssetRepository _localAssetRepository;
+ final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
+ final LocalFilesManagerRepository _localFilesManager;
+ final StorageRepository _storageRepository;
final bool Function()? _cancelChecker;
SyncStreamService({
required SyncApiRepository syncApiRepository,
required SyncStreamRepository syncStreamRepository,
+ required DriftLocalAssetRepository localAssetRepository,
+ required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
+ required LocalFilesManagerRepository localFilesManager,
+ required StorageRepository storageRepository,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
+ _localAssetRepository = localAssetRepository,
+ _trashedLocalAssetRepository = trashedLocalAssetRepository,
+ _localFilesManager = localFilesManager,
+ _storageRepository = storageRepository,
_cancelChecker = cancelChecker;
bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -83,7 +102,18 @@ class SyncStreamService {
case SyncEntityType.partnerDeleteV1:
return _syncStreamRepository.deletePartnerV1(data.cast());
case SyncEntityType.assetV1:
- return _syncStreamRepository.updateAssetsV1(data.cast());
+ final remoteSyncAssets = data.cast();
+ await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
+ if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
+ final hasPermission = await _localFilesManager.hasManageMediaPermission();
+ if (hasPermission) {
+ await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
+ await _applyRemoteRestoreToLocal();
+ } else {
+ _logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
+ }
+ }
+ return;
case SyncEntityType.assetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
@@ -132,7 +162,8 @@ class SyncStreamService {
return;
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
case SyncEntityType.syncCompleteV1:
- return _syncStreamRepository.pruneAssets();
+ return;
+ // return _syncStreamRepository.pruneAssets();
// Request to reset the client state. Clear everything related to remote entities
case SyncEntityType.syncResetV1:
return _syncStreamRepository.reset();
@@ -211,4 +242,36 @@ class SyncStreamService {
_logger.severe("Error processing AssetUploadReadyV1 websocket batch events", error, stackTrace);
}
}
+
+ Future _handleRemoteTrashed(Iterable checksums) async {
+ if (checksums.isEmpty) {
+ return Future.value();
+ } else {
+ final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
+ if (localAssetsToTrash.isNotEmpty) {
+ final mediaUrls = await Future.wait(
+ localAssetsToTrash.values
+ .expand((e) => e)
+ .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
+ );
+ _logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
+ final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
+ if (result) {
+ await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
+ }
+ } else {
+ _logger.info("No assets found in backup-enabled albums for assets: $checksums");
+ }
+ }
+ }
+
+ Future _applyRemoteRestoreToLocal() async {
+ final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
+ if (assetsToRestore.isNotEmpty) {
+ final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
+ await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
+ } else {
+ _logger.info("No remote assets found for restoration");
+ }
+ }
}
diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart
new file mode 100644
index 0000000000..308130b9ea
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart
@@ -0,0 +1,40 @@
+import 'package:drift/drift.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
+import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
+import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
+
+@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
+@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
+class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
+ const TrashedLocalAssetEntity();
+
+ TextColumn get id => text()();
+
+ TextColumn get albumId => text()();
+
+ TextColumn get checksum => text().nullable()();
+
+ BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
+
+ IntColumn get orientation => integer().withDefault(const Constant(0))();
+
+ @override
+ Set get primaryKey => {id, albumId};
+}
+
+extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData {
+ LocalAsset toLocalAsset() => LocalAsset(
+ id: id,
+ name: name,
+ checksum: checksum,
+ type: type,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ durationInSeconds: durationInSeconds,
+ isFavorite: isFavorite,
+ height: height,
+ width: width,
+ orientation: orientation,
+ );
+}
diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart
new file mode 100644
index 0000000000..aab226c3a2
--- /dev/null
+++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart
@@ -0,0 +1,1080 @@
+// dart format width=80
+// ignore_for_file: type=lint
+import 'package:drift/drift.dart' as i0;
+import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
+ as i1;
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2;
+import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'
+ as i3;
+import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
+
+typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
+ i1.TrashedLocalAssetEntityCompanion Function({
+ required String name,
+ required i2.AssetType type,
+ i0.Value createdAt,
+ i0.Value updatedAt,
+ i0.Value width,
+ i0.Value height,
+ i0.Value durationInSeconds,
+ required String id,
+ required String albumId,
+ i0.Value checksum,
+ i0.Value isFavorite,
+ i0.Value orientation,
+ });
+typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
+ i1.TrashedLocalAssetEntityCompanion Function({
+ i0.Value name,
+ i0.Value type,
+ i0.Value createdAt,
+ i0.Value updatedAt,
+ i0.Value width,
+ i0.Value height,
+ i0.Value durationInSeconds,
+ i0.Value id,
+ i0.Value albumId,
+ i0.Value checksum,
+ i0.Value isFavorite,
+ i0.Value orientation,
+ });
+
+class $$TrashedLocalAssetEntityTableFilterComposer
+ extends
+ i0.Composer {
+ $$TrashedLocalAssetEntityTableFilterComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ i0.ColumnFilters get name => $composableBuilder(
+ column: $table.name,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnWithTypeConverterFilters get type =>
+ $composableBuilder(
+ column: $table.type,
+ builder: (column) => i0.ColumnWithTypeConverterFilters(column),
+ );
+
+ i0.ColumnFilters get createdAt => $composableBuilder(
+ column: $table.createdAt,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get updatedAt => $composableBuilder(
+ column: $table.updatedAt,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get width => $composableBuilder(
+ column: $table.width,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get height => $composableBuilder(
+ column: $table.height,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get durationInSeconds => $composableBuilder(
+ column: $table.durationInSeconds,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get id => $composableBuilder(
+ column: $table.id,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get albumId => $composableBuilder(
+ column: $table.albumId,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get checksum => $composableBuilder(
+ column: $table.checksum,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get isFavorite => $composableBuilder(
+ column: $table.isFavorite,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
+ i0.ColumnFilters get orientation => $composableBuilder(
+ column: $table.orientation,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+}
+
+class $$TrashedLocalAssetEntityTableOrderingComposer
+ extends
+ i0.Composer {
+ $$TrashedLocalAssetEntityTableOrderingComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ i0.ColumnOrderings get name => $composableBuilder(
+ column: $table.name,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get type => $composableBuilder(
+ column: $table.type,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get createdAt => $composableBuilder(
+ column: $table.createdAt,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get updatedAt => $composableBuilder(
+ column: $table.updatedAt,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get width => $composableBuilder(
+ column: $table.width,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get height => $composableBuilder(
+ column: $table.height,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get durationInSeconds => $composableBuilder(
+ column: $table.durationInSeconds,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get id => $composableBuilder(
+ column: $table.id,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get albumId => $composableBuilder(
+ column: $table.albumId,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get checksum => $composableBuilder(
+ column: $table.checksum,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get isFavorite => $composableBuilder(
+ column: $table.isFavorite,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
+ i0.ColumnOrderings get orientation => $composableBuilder(
+ column: $table.orientation,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+}
+
+class $$TrashedLocalAssetEntityTableAnnotationComposer
+ extends
+ i0.Composer {
+ $$TrashedLocalAssetEntityTableAnnotationComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ i0.GeneratedColumn get name =>
+ $composableBuilder(column: $table.name, builder: (column) => column);
+
+ i0.GeneratedColumnWithTypeConverter get type =>
+ $composableBuilder(column: $table.type, builder: (column) => column);
+
+ i0.GeneratedColumn get createdAt =>
+ $composableBuilder(column: $table.createdAt, builder: (column) => column);
+
+ i0.GeneratedColumn get updatedAt =>
+ $composableBuilder(column: $table.updatedAt, builder: (column) => column);
+
+ i0.GeneratedColumn get width =>
+ $composableBuilder(column: $table.width, builder: (column) => column);
+
+ i0.GeneratedColumn get height =>
+ $composableBuilder(column: $table.height, builder: (column) => column);
+
+ i0.GeneratedColumn get durationInSeconds => $composableBuilder(
+ column: $table.durationInSeconds,
+ builder: (column) => column,
+ );
+
+ i0.GeneratedColumn get id =>
+ $composableBuilder(column: $table.id, builder: (column) => column);
+
+ i0.GeneratedColumn get albumId =>
+ $composableBuilder(column: $table.albumId, builder: (column) => column);
+
+ i0.GeneratedColumn get checksum =>
+ $composableBuilder(column: $table.checksum, builder: (column) => column);
+
+ i0.GeneratedColumn get isFavorite => $composableBuilder(
+ column: $table.isFavorite,
+ builder: (column) => column,
+ );
+
+ i0.GeneratedColumn get orientation => $composableBuilder(
+ column: $table.orientation,
+ builder: (column) => column,
+ );
+}
+
+class $$TrashedLocalAssetEntityTableTableManager
+ extends
+ i0.RootTableManager<
+ i0.GeneratedDatabase,
+ i1.$TrashedLocalAssetEntityTable,
+ i1.TrashedLocalAssetEntityData,
+ i1.$$TrashedLocalAssetEntityTableFilterComposer,
+ i1.$$TrashedLocalAssetEntityTableOrderingComposer,
+ i1.$$TrashedLocalAssetEntityTableAnnotationComposer,
+ $$TrashedLocalAssetEntityTableCreateCompanionBuilder,
+ $$TrashedLocalAssetEntityTableUpdateCompanionBuilder,
+ (
+ i1.TrashedLocalAssetEntityData,
+ i0.BaseReferences<
+ i0.GeneratedDatabase,
+ i1.$TrashedLocalAssetEntityTable,
+ i1.TrashedLocalAssetEntityData
+ >,
+ ),
+ i1.TrashedLocalAssetEntityData,
+ i0.PrefetchHooks Function()
+ > {
+ $$TrashedLocalAssetEntityTableTableManager(
+ i0.GeneratedDatabase db,
+ i1.$TrashedLocalAssetEntityTable table,
+ ) : super(
+ i0.TableManagerState(
+ db: db,
+ table: table,
+ createFilteringComposer: () =>
+ i1.$$TrashedLocalAssetEntityTableFilterComposer(
+ $db: db,
+ $table: table,
+ ),
+ createOrderingComposer: () =>
+ i1.$$TrashedLocalAssetEntityTableOrderingComposer(
+ $db: db,
+ $table: table,
+ ),
+ createComputedFieldComposer: () =>
+ i1.$$TrashedLocalAssetEntityTableAnnotationComposer(
+ $db: db,
+ $table: table,
+ ),
+ updateCompanionCallback:
+ ({
+ i0.Value name = const i0.Value.absent(),
+ i0.Value type = const i0.Value.absent(),
+ i0.Value createdAt = const i0.Value.absent(),
+ i0.Value updatedAt = const i0.Value.absent(),
+ i0.Value width = const i0.Value.absent(),
+ i0.Value height = const i0.Value.absent(),
+ i0.Value durationInSeconds = const i0.Value.absent(),
+ i0.Value id = const i0.Value.absent(),
+ i0.Value albumId = const i0.Value.absent(),
+ i0.Value checksum = const i0.Value.absent(),
+ i0.Value isFavorite = const i0.Value.absent(),
+ i0.Value orientation = const i0.Value.absent(),
+ }) => i1.TrashedLocalAssetEntityCompanion(
+ name: name,
+ type: type,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ width: width,
+ height: height,
+ durationInSeconds: durationInSeconds,
+ id: id,
+ albumId: albumId,
+ checksum: checksum,
+ isFavorite: isFavorite,
+ orientation: orientation,
+ ),
+ createCompanionCallback:
+ ({
+ required String name,
+ required i2.AssetType type,
+ i0.Value createdAt = const i0.Value.absent(),
+ i0.Value updatedAt = const i0.Value.absent(),
+ i0.Value width = const i0.Value.absent(),
+ i0.Value height = const i0.Value.absent(),
+ i0.Value durationInSeconds = const i0.Value.absent(),
+ required String id,
+ required String albumId,
+ i0.Value checksum = const i0.Value.absent(),
+ i0.Value isFavorite = const i0.Value.absent(),
+ i0.Value orientation = const i0.Value.absent(),
+ }) => i1.TrashedLocalAssetEntityCompanion.insert(
+ name: name,
+ type: type,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ width: width,
+ height: height,
+ durationInSeconds: durationInSeconds,
+ id: id,
+ albumId: albumId,
+ checksum: checksum,
+ isFavorite: isFavorite,
+ orientation: orientation,
+ ),
+ withReferenceMapper: (p0) => p0
+ .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
+ .toList(),
+ prefetchHooksCallback: null,
+ ),
+ );
+}
+
+typedef $$TrashedLocalAssetEntityTableProcessedTableManager =
+ i0.ProcessedTableManager<
+ i0.GeneratedDatabase,
+ i1.$TrashedLocalAssetEntityTable,
+ i1.TrashedLocalAssetEntityData,
+ i1.$$TrashedLocalAssetEntityTableFilterComposer,
+ i1.$$TrashedLocalAssetEntityTableOrderingComposer,
+ i1.$$TrashedLocalAssetEntityTableAnnotationComposer,
+ $$TrashedLocalAssetEntityTableCreateCompanionBuilder,
+ $$TrashedLocalAssetEntityTableUpdateCompanionBuilder,
+ (
+ i1.TrashedLocalAssetEntityData,
+ i0.BaseReferences<
+ i0.GeneratedDatabase,
+ i1.$TrashedLocalAssetEntityTable,
+ i1.TrashedLocalAssetEntityData
+ >,
+ ),
+ i1.TrashedLocalAssetEntityData,
+ i0.PrefetchHooks Function()
+ >;
+i0.Index get idxTrashedLocalAssetChecksum => i0.Index(
+ 'idx_trashed_local_asset_checksum',
+ 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
+);
+
+class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
+ with
+ i0.TableInfo<
+ $TrashedLocalAssetEntityTable,
+ i1.TrashedLocalAssetEntityData
+ > {
+ @override
+ final i0.GeneratedDatabase attachedDatabase;
+ final String? _alias;
+ $TrashedLocalAssetEntityTable(this.attachedDatabase, [this._alias]);
+ static const i0.VerificationMeta _nameMeta = const i0.VerificationMeta(
+ 'name',
+ );
+ @override
+ late final i0.GeneratedColumn name = i0.GeneratedColumn(
+ 'name',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ @override
+ late final i0.GeneratedColumnWithTypeConverter type =
+ i0.GeneratedColumn(
+ 'type',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.int,
+ requiredDuringInsert: true,
+ ).withConverter(
+ i1.$TrashedLocalAssetEntityTable.$convertertype,
+ );
+ static const i0.VerificationMeta _createdAtMeta = const i0.VerificationMeta(
+ 'createdAt',
+ );
+ @override
+ late final i0.GeneratedColumn createdAt =
+ i0.GeneratedColumn(
+ 'created_at',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.dateTime,
+ requiredDuringInsert: false,
+ defaultValue: i4.currentDateAndTime,
+ );
+ static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
+ 'updatedAt',
+ );
+ @override
+ late final i0.GeneratedColumn updatedAt =
+ i0.GeneratedColumn(
+ 'updated_at',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.dateTime,
+ requiredDuringInsert: false,
+ defaultValue: i4.currentDateAndTime,
+ );
+ static const i0.VerificationMeta _widthMeta = const i0.VerificationMeta(
+ 'width',
+ );
+ @override
+ late final i0.GeneratedColumn width = i0.GeneratedColumn(
+ 'width',
+ aliasedName,
+ true,
+ type: i0.DriftSqlType.int,
+ requiredDuringInsert: false,
+ );
+ static const i0.VerificationMeta _heightMeta = const i0.VerificationMeta(
+ 'height',
+ );
+ @override
+ late final i0.GeneratedColumn height = i0.GeneratedColumn(
+ 'height',
+ aliasedName,
+ true,
+ type: i0.DriftSqlType.int,
+ requiredDuringInsert: false,
+ );
+ static const i0.VerificationMeta _durationInSecondsMeta =
+ const i0.VerificationMeta('durationInSeconds');
+ @override
+ late final i0.GeneratedColumn durationInSeconds =
+ i0.GeneratedColumn(
+ 'duration_in_seconds',
+ aliasedName,
+ true,
+ type: i0.DriftSqlType.int,
+ requiredDuringInsert: false,
+ );
+ static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
+ @override
+ late final i0.GeneratedColumn id = i0.GeneratedColumn(
+ 'id',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ static const i0.VerificationMeta _albumIdMeta = const i0.VerificationMeta(
+ 'albumId',
+ );
+ @override
+ late final i0.GeneratedColumn albumId = i0.GeneratedColumn(
+ 'album_id',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
+ 'checksum',
+ );
+ @override
+ late final i0.GeneratedColumn checksum = i0.GeneratedColumn(
+ 'checksum',
+ aliasedName,
+ true,
+ type: i0.DriftSqlType.string,
+ requiredDuringInsert: false,
+ );
+ static const i0.VerificationMeta _isFavoriteMeta = const i0.VerificationMeta(
+ 'isFavorite',
+ );
+ @override
+ late final i0.GeneratedColumn isFavorite = i0.GeneratedColumn(
+ 'is_favorite',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.bool,
+ requiredDuringInsert: false,
+ defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
+ 'CHECK ("is_favorite" IN (0, 1))',
+ ),
+ defaultValue: const i4.Constant(false),
+ );
+ static const i0.VerificationMeta _orientationMeta = const i0.VerificationMeta(
+ 'orientation',
+ );
+ @override
+ late final i0.GeneratedColumn orientation = i0.GeneratedColumn(
+ 'orientation',
+ aliasedName,
+ false,
+ type: i0.DriftSqlType.int,
+ requiredDuringInsert: false,
+ defaultValue: const i4.Constant(0),
+ );
+ @override
+ List get $columns => [
+ name,
+ type,
+ createdAt,
+ updatedAt,
+ width,
+ height,
+ durationInSeconds,
+ id,
+ albumId,
+ checksum,
+ isFavorite,
+ orientation,
+ ];
+ @override
+ String get aliasedName => _alias ?? actualTableName;
+ @override
+ String get actualTableName => $name;
+ static const String $name = 'trashed_local_asset_entity';
+ @override
+ i0.VerificationContext validateIntegrity(
+ i0.Insertable instance, {
+ bool isInserting = false,
+ }) {
+ final context = i0.VerificationContext();
+ final data = instance.toColumns(true);
+ if (data.containsKey('name')) {
+ context.handle(
+ _nameMeta,
+ name.isAcceptableOrUnknown(data['name']!, _nameMeta),
+ );
+ } else if (isInserting) {
+ context.missing(_nameMeta);
+ }
+ if (data.containsKey('created_at')) {
+ context.handle(
+ _createdAtMeta,
+ createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
+ );
+ }
+ if (data.containsKey('updated_at')) {
+ context.handle(
+ _updatedAtMeta,
+ updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
+ );
+ }
+ if (data.containsKey('width')) {
+ context.handle(
+ _widthMeta,
+ width.isAcceptableOrUnknown(data['width']!, _widthMeta),
+ );
+ }
+ if (data.containsKey('height')) {
+ context.handle(
+ _heightMeta,
+ height.isAcceptableOrUnknown(data['height']!, _heightMeta),
+ );
+ }
+ if (data.containsKey('duration_in_seconds')) {
+ context.handle(
+ _durationInSecondsMeta,
+ durationInSeconds.isAcceptableOrUnknown(
+ data['duration_in_seconds']!,
+ _durationInSecondsMeta,
+ ),
+ );
+ }
+ if (data.containsKey('id')) {
+ context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
+ } else if (isInserting) {
+ context.missing(_idMeta);
+ }
+ if (data.containsKey('album_id')) {
+ context.handle(
+ _albumIdMeta,
+ albumId.isAcceptableOrUnknown(data['album_id']!, _albumIdMeta),
+ );
+ } else if (isInserting) {
+ context.missing(_albumIdMeta);
+ }
+ if (data.containsKey('checksum')) {
+ context.handle(
+ _checksumMeta,
+ checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
+ );
+ }
+ if (data.containsKey('is_favorite')) {
+ context.handle(
+ _isFavoriteMeta,
+ isFavorite.isAcceptableOrUnknown(data['is_favorite']!, _isFavoriteMeta),
+ );
+ }
+ if (data.containsKey('orientation')) {
+ context.handle(
+ _orientationMeta,
+ orientation.isAcceptableOrUnknown(
+ data['orientation']!,
+ _orientationMeta,
+ ),
+ );
+ }
+ return context;
+ }
+
+ @override
+ Set get $primaryKey => {id, albumId};
+ @override
+ i1.TrashedLocalAssetEntityData map(
+ Map data, {
+ String? tablePrefix,
+ }) {
+ final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+ return i1.TrashedLocalAssetEntityData(
+ name: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.string,
+ data['${effectivePrefix}name'],
+ )!,
+ type: i1.$TrashedLocalAssetEntityTable.$convertertype.fromSql(
+ attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.int,
+ data['${effectivePrefix}type'],
+ )!,
+ ),
+ createdAt: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.dateTime,
+ data['${effectivePrefix}created_at'],
+ )!,
+ updatedAt: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.dateTime,
+ data['${effectivePrefix}updated_at'],
+ )!,
+ width: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.int,
+ data['${effectivePrefix}width'],
+ ),
+ height: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.int,
+ data['${effectivePrefix}height'],
+ ),
+ durationInSeconds: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.int,
+ data['${effectivePrefix}duration_in_seconds'],
+ ),
+ id: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.string,
+ data['${effectivePrefix}id'],
+ )!,
+ albumId: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.string,
+ data['${effectivePrefix}album_id'],
+ )!,
+ checksum: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.string,
+ data['${effectivePrefix}checksum'],
+ ),
+ isFavorite: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.bool,
+ data['${effectivePrefix}is_favorite'],
+ )!,
+ orientation: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.int,
+ data['${effectivePrefix}orientation'],
+ )!,
+ );
+ }
+
+ @override
+ $TrashedLocalAssetEntityTable createAlias(String alias) {
+ return $TrashedLocalAssetEntityTable(attachedDatabase, alias);
+ }
+
+ static i0.JsonTypeConverter2 $convertertype =
+ const i0.EnumIndexConverter