diff --git a/.github/.nvmrc b/.github/.nvmrc index b8ffd70759..8320a6d299 100644 --- a/.github/.nvmrc +++ b/.github/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/.github/actions/image-build/action.yml b/.github/actions/image-build/action.yml index ee23bd8ba8..a4168dcd5a 100644 --- a/.github/actions/image-build/action.yml +++ b/.github/actions/image-build/action.yml @@ -84,7 +84,7 @@ runs: - name: Build and push image id: build - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 with: context: ${{ inputs.context }} file: ${{ inputs.dockerfile }} diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 4e0bf12fdc..74f5970139 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -96,7 +96,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 73c5d5945a..c04adbafc6 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -150,7 +150,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 + uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -165,7 +165,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 + uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -199,7 +199,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 + uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 with: tg_version: '0.58.12' tofu_version: '1.7.1' diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 778cba77e1..cd095b117f 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -25,7 +25,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 + uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 with: tg_version: '0.58.12' tofu_version: '1.7.1' diff --git a/.github/workflows/multi-runner-build.yml b/.github/workflows/multi-runner-build.yml index 17eceb7e8f..f6d7c12355 100644 --- a/.github/workflows/multi-runner-build.yml +++ b/.github/workflows/multi-runner-build.yml @@ -115,7 +115,7 @@ jobs: packages: write steps: - name: Download digests - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: ${{ runner.temp }}/digests pattern: ${{ needs.matrix.outputs.key }}-* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 91f4ffce4f..e6aecdb403 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -643,7 +643,7 @@ jobs: contents: read services: postgres: - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 + image: ghcr.io/immich-app/postgres:14@sha256:14bec5d02e8704081eafd566029204a4eb6bb75f3056cfb34e81c5ab1657a490 env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres diff --git a/cli/.nvmrc b/cli/.nvmrc index b8ffd70759..8320a6d299 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/cli/package-lock.json b/cli/package-lock.json index bc4a710b46..8680ae54bd 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -27,7 +27,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", @@ -61,7 +61,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "typescript": "^5.3.3" } }, @@ -1372,9 +1372,9 @@ } }, "node_modules/@types/node": { - "version": "22.15.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", - "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "version": "22.15.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", + "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 74a97ccaec..40c19c91b1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -21,7 +21,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", @@ -69,6 +69,6 @@ "micromatch": "^4.0.8" }, "volta": { - "node": "22.15.0" + "node": "22.15.1" } } diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a428934022..1da06ef2ff 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -122,7 +122,7 @@ services: database: container_name: immich_postgres - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 + image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0 env_file: - .env environment: @@ -134,24 +134,6 @@ services: - ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data ports: - 5432:5432 - healthcheck: - test: >- - pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; - Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align - --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; - echo "checksum failure count is $$Chksum"; - [ "$$Chksum" = '0' ] || exit 1 - interval: 5m - start_interval: 30s - start_period: 5m - command: >- - postgres - -c shared_preload_libraries=vectors.so - -c 'search_path="$$user", public, vectors' - -c logging_collector=on - -c max_wal_size=2GB - -c shared_buffers=512MB - -c wal_compression=on # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # immich-prometheus: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index bfcb5455aa..efe4271209 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -63,7 +63,7 @@ services: database: container_name: immich_postgres - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 + image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0 env_file: - .env environment: @@ -75,24 +75,6 @@ services: - ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data ports: - 5432:5432 - healthcheck: - test: >- - pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; - Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align - --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; - echo "checksum failure count is $$Chksum"; - [ "$$Chksum" = '0' ] || exit 1 - interval: 5m - start_interval: 30s - start_period: 5m - command: >- - postgres - -c shared_preload_libraries=vectors.so - -c 'search_path="$$user", public, vectors' - -c logging_collector=on - -c max_wal_size=2GB - -c shared_buffers=512MB - -c wal_compression=on restart: always # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics @@ -100,7 +82,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:e2b8aa62b64855956e3ec1e18b4f9387fb6203174a4471936f4662f437f04405 + image: prom/prometheus@sha256:78ed1f9050eb9eaf766af6e580230b1c4965728650e332cd1ee918c0c4699775 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4387f5fd0c..f2b1a20321 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -56,7 +56,7 @@ services: database: container_name: immich_postgres - image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 + image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} @@ -65,24 +65,8 @@ services: volumes: # Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - healthcheck: - test: >- - pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; - Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align - --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; - echo "checksum failure count is $$Chksum"; - [ "$$Chksum" = '0' ] || exit 1 - interval: 5m - start_interval: 30s - start_period: 5m - command: >- - postgres - -c shared_preload_libraries=vectors.so - -c 'search_path="$$user", public, vectors' - -c logging_collector=on - -c max_wal_size=2GB - -c shared_buffers=512MB - -c wal_compression=on + # change ssd below to hdd if you are using a hard disk drive or other slow storage + command: postgres -c config_file=/etc/postgresql/postgresql.ssd.conf restart: always volumes: diff --git a/docs/.nvmrc b/docs/.nvmrc index b8ffd70759..8320a6d299 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index 2ca23e195f..44c2c8e4c6 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -10,12 +10,12 @@ Running with a pre-existing Postgres server can unlock powerful administrative f ## Prerequisites -You must install pgvecto.rs into your instance of Postgres using their [instructions][vectors-install]. After installation, add `shared_preload_libraries = 'vectors.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, vectors.so'`. +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, 15, and 16. Earlier versions are unsupported. Postgres 17 is nominally compatible, but pgvecto.rs does not have prebuilt images or packages for it as of writing. +Immich is known to work with Postgres versions 14, 15, 16 and 17. Earlier versions are unsupported. -Make sure the installed version of pgvecto.rs is compatible with your version of Immich. The current accepted range for pgvecto.rs is `>= 0.2.0, < 0.4.0`. +Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 1.0.0`. ::: ## Specifying the connection URL @@ -53,16 +53,75 @@ CREATE DATABASE <immichdatabasename>; \c <immichdatabasename> BEGIN; ALTER DATABASE <immichdatabasename> OWNER TO <immichdbusername>; -CREATE EXTENSION vectors; +CREATE EXTENSION vchord CASCADE; CREATE EXTENSION earthdistance CASCADE; -ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors; -ALTER SCHEMA vectors OWNER TO <immichdbusername>; COMMIT; ``` -### Updating pgvecto.rs +### Updating VectorChord -When installing a new version of pgvecto.rs, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vectors UPDATE;`. +When installing a new version of VectorChord, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vchord UPDATE;`. + +## Migrating to VectorChord + +VectorChord is the successor extension to pgvecto.rs, allowing for higher performance, lower memory usage and higher quality results for smart search and facial recognition. + +### Migrating from pgvecto.rs + +Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so. + +The easiest option is to have both extensions installed during the migration: + +1. Ensure you still have pgvecto.rs installed +2. [Install VectorChord][vchord-install] +3. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed +4. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` using psql or your choice of database client +5. Start Immich and wait for the logs `Reindexed face_index` and `Reindexed clip_index` to be output +6. Remove the `vectors.so` entry from the `shared_preload_libraries` setting +7. Uninstall pgvecto.rs (e.g. `apt-get purge vectors-pg14` on Debian-based environments, replacing `pg14` as appropriate) + +If it is not possible to have both VectorChord and pgvector.s installed at the same time, you can perform the migration with more manual steps: + +1. While pgvecto.rs is still installed, run the following SQL command using psql or your choice of database client. Take note of the number outputted by this command as you will need it later + +```sql +SELECT atttypmod as dimsize + FROM pg_attribute f + JOIN pg_class c ON c.oid = f.attrelid + WHERE c.relkind = 'r'::char + AND f.attnum > 0 + AND c.relname = 'smart_search'::text + AND f.attname = 'embedding'::text; +``` + +2. Remove references to pgvecto.rs using the below SQL commands + +```sql +DROP INDEX IF EXISTS clip_index; +DROP INDEX IF EXISTS face_index; +ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE real[]; +ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE real[]; +``` + +3. [Install VectorChord][vchord-install] +4. Change the columns back to the appropriate vector types, replacing `<number>` with the number from step 1 + +```sql +CREATE EXTENSION IF NOT EXISTS vchord CASCADE; +ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(<number>); +ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512); +``` + +5. Start Immich and let it create new indices using VectorChord + +### Migrating from pgvector + +1. Ensure you have at least 0.7.0 of pgvector installed. If it is below that, please upgrade it and run the SQL command `ALTER EXTENSION vector UPDATE;` using psql or your choice of database client +2. Follow the Prerequisites to install VectorChord +3. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` +4. Start Immich and let it create new indices using VectorChord + +Note that VectorChord itself uses pgvector types, so you should not uninstall pgvector after following these steps. ### Common errors @@ -70,4 +129,4 @@ When installing a new version of pgvecto.rs, you will need to manually update th If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO <immichdbusername>;`. -[vectors-install]: https://docs.vectorchord.ai/getting-started/installation.html +[vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html diff --git a/docs/docs/features/searching.md b/docs/docs/features/searching.md index f6bfac6e7a..d7ebd1a468 100644 --- a/docs/docs/features/searching.md +++ b/docs/docs/features/searching.md @@ -5,7 +5,7 @@ import TabItem from '@theme/TabItem'; Immich uses Postgres as its search database for both metadata and contextual CLIP search. -Contextual CLIP search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvecto.rs) extension, utilizing machine learning models like [CLIP](https://openai.com/research/clip) to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata. +Contextual CLIP search is powered by the [VectorChord](https://github.com/tensorchord/VectorChord) extension, utilizing machine learning models like [CLIP](https://openai.com/research/clip) to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata. ## Advanced Search Filters diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index c853a873ab..d3ca49a0a4 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -72,21 +72,21 @@ Information on the current workers can be found [here](/docs/administration/jobs ## Database -| Variable | Description | Default | Containers | -| :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :----------------------------- | -| `DB_URL` | Database URL | | server | -| `DB_HOSTNAME` | Database host | `database` | server | -| `DB_PORT` | Database port | `5432` | server | -| `DB_USERNAME` | Database user | `postgres` | server, database<sup>\*1</sup> | -| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> | -| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> | -| `DB_SSL_MODE` | Database SSL mode | | server | -| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server | -| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | +| Variable | Description | Default | Containers | +| :---------------------------------- | :--------------------------------------------------------------------------- | :--------: | :----------------------------- | +| `DB_URL` | Database URL | | server | +| `DB_HOSTNAME` | Database host | `database` | server | +| `DB_PORT` | Database port | `5432` | server | +| `DB_USERNAME` | Database user | `postgres` | server, database<sup>\*1</sup> | +| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> | +| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> | +| `DB_SSL_MODE` | Database SSL mode | | server | +| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server | +| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | \*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`. -\*2: This setting cannot be changed after the server has successfully started up. +\*2: If not provided, the appropriate extension to use is auto-detected at startup by introspecting the database. When multiple extensions are installed, the order of preference is VectorChord, pgvecto.rs, pgvector. :::info diff --git a/docs/package.json b/docs/package.json index b20303c4ab..05ca51d6f4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -57,6 +57,6 @@ "node": ">=20" }, "volta": { - "node": "22.15.0" + "node": "22.15.1" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index b8ffd70759..8320a6d299 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 48c17c828b..a8cb21aaf7 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -37,8 +37,8 @@ services: image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa database: - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 - command: -c fsync=off -c shared_preload_libraries=vectors.so + image: ghcr.io/immich-app/postgres:14 + command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres diff --git a/e2e/package-lock.json b/e2e/package-lock.json index eb0de90a39..cbce017e08 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", @@ -66,7 +66,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", @@ -100,7 +100,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "typescript": "^5.3.3" } }, @@ -1593,9 +1593,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", - "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "version": "22.15.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", + "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index b792d1aaf6..fc0196fb99 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", @@ -52,6 +52,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.15.0" + "node": "22.15.1" } } diff --git a/i18n/az.json b/i18n/az.json index fe53041b69..0b787b317f 100644 --- a/i18n/az.json +++ b/i18n/az.json @@ -76,7 +76,6 @@ "library_watching_settings_description": "Dəyişdirilən faylları avtomatik olaraq yoxla", "logging_enable_description": "Jurnalı aktivləşdir", "logging_level_description": "Aktiv edildikdə hansı jurnal səviyyəsi istifadə olunur.", - "logging_settings": "", "machine_learning_clip_model": "CLIP modeli", "machine_learning_clip_model_description": "<link>Burada</link>qeyd olunan CLIP modelinin adı. Modeli dəyişdirdikdən sonra bütün şəkillər üçün 'Ağıllı Axtarış' funksiyasını yenidən işə salmalısınız.", "machine_learning_duplicate_detection": "Dublikat Aşkarlama", diff --git a/i18n/bi.json b/i18n/bi.json index 8a4f4a6193..fff8196e75 100644 --- a/i18n/bi.json +++ b/i18n/bi.json @@ -3,8 +3,6 @@ "account": "Akaont", "account_settings": "Seting blo Akaont", "acknowledge": "Akcept", - "action": "", - "actions": "", "active": "Stap Mekem", "activity": "Wanem hemi Mekem", "activity_changed": "WAnem hemi Mekem hemi", @@ -16,845 +14,5 @@ "add_exclusion_pattern": "Putem wan paten wae hemi karem aot", "add_import_path": "Putem wan pat blo import", "add_location": "Putem wan place blo hem", - "add_more_users": "Putem mor man", - "add_partner": "", - "add_path": "", - "add_photos": "", - "add_to": "", - "add_to_album": "", - "add_to_shared_album": "", - "admin": { - "add_exclusion_pattern_description": "", - "authentication_settings": "", - "authentication_settings_description": "", - "background_task_job": "", - "check_all": "", - "config_set_by_file": "", - "confirm_delete_library": "", - "confirm_delete_library_assets": "", - "confirm_email_below": "", - "confirm_reprocess_all_faces": "", - "confirm_user_password_reset": "", - "disable_login": "", - "duplicate_detection_job_description": "", - "exclusion_pattern_description": "", - "external_library_created_at": "", - "external_library_management": "", - "face_detection": "", - "face_detection_description": "", - "facial_recognition_job_description": "", - "force_delete_user_warning": "", - "forcing_refresh_library_files": "", - "image_format_description": "", - "image_prefer_embedded_preview": "", - "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", - "image_prefer_wide_gamut_setting_description": "", - "image_quality": "", - "image_settings": "", - "image_settings_description": "", - "job_concurrency": "", - "job_not_concurrency_safe": "", - "job_settings": "", - "job_settings_description": "", - "job_status": "", - "jobs_delayed": "", - "jobs_failed": "", - "library_created": "", - "library_deleted": "", - "library_import_path_description": "", - "library_scanning": "", - "library_scanning_description": "", - "library_scanning_enable_description": "", - "library_settings": "", - "library_settings_description": "", - "library_tasks_description": "", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", - "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", - "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", - "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", - "machine_learning_settings": "", - "machine_learning_settings_description": "", - "machine_learning_smart_search": "", - "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", - "manage_concurrency": "", - "manage_log_settings": "", - "map_dark_style": "", - "map_enable_description": "", - "map_light_style": "", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", - "map_settings": "", - "map_settings_description": "", - "map_style_description": "", - "metadata_extraction_job": "", - "metadata_extraction_job_description": "", - "migration_job": "", - "migration_job_description": "", - "no_paths_added": "", - "no_pattern_added": "", - "note_apply_storage_label_previous_assets": "", - "note_cannot_be_changed_later": "", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", - "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", - "notification_settings": "", - "notification_settings_description": "", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_enable_description": "", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_settings": "", - "oauth_settings_description": "", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", - "oauth_storage_quota_default_description": "", - "offline_paths": "", - "offline_paths_description": "", - "password_enable_description": "", - "password_settings": "", - "password_settings_description": "", - "paths_validated_successfully": "", - "quota_size_gib": "", - "refreshing_all_libraries": "", - "repair_all": "", - "repair_matched_items": "", - "repaired_items": "", - "require_password_change_on_login": "", - "reset_settings_to_default": "", - "reset_settings_to_recent_saved": "", - "send_welcome_email": "", - "server_external_domain_settings": "", - "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", - "server_welcome_message": "", - "server_welcome_message_description": "", - "sidecar_job": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "system_settings": "", - "theme_custom_css_settings": "", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "these_files_matched_by_checksum": "", - "thumbnail_generation_job": "", - "thumbnail_generation_job_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" - }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", - "album_added": "", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", - "album_updated_setting_description": "", - "albums": "", - "albums_count": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", - "back": "", - "backward": "", - "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", - "change_date": "", - "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", - "choose_matching_people_to_merge": "", - "city": "", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", - "confirm_delete_shared_link": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", - "copied_image_to_clipboard": "", - "copied_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", - "create_link": "", - "create_link_to_share": "", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", - "day": "", - "default_locale": "", - "default_locale_description": "", - "delete": "", - "delete_album": "", - "delete_api_key_prompt": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", - "delete_shared_link": "", - "delete_user": "", - "deleted_shared_link": "", - "description": "", - "details": "", - "direction": "", - "disabled": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "", - "done": "", - "download": "", - "downloading": "", - "duration": "", - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", - "edit_link": "", - "edit_location": "", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", - "empty_trash": "", - "enable": "", - "enabled": "", - "end_date": "", - "error": "", - "error_loading_image": "", - "errors": { - "cleared_jobs": "", - "exclusion_pattern_already_exists": "", - "failed_job_command": "", - "import_path_already_exists": "", - "paths_validation_failed": "", - "quota_higher_than_disk_size": "", - "repair_unable_to_check_items": "", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_exclusion_pattern": "", - "unable_to_add_import_path": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_exclusion_pattern": "", - "unable_to_delete_import_path": "", - "unable_to_delete_shared_link": "", - "unable_to_delete_user": "", - "unable_to_edit_exclusion_pattern": "", - "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_link_oauth_account": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_api_key": "", - "unable_to_remove_deleted_assets": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_api_key": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_timeline_display_status": "", - "unable_to_update_user": "" - }, - "exit_slideshow": "", - "expand_all": "", - "expire_after": "", - "expired": "", - "explore": "", - "export": "", - "export_as_json": "", - "extension": "", - "external": "", - "external_libraries": "", - "favorite": "", - "favorite_or_unfavorite_photo": "", - "favorites": "", - "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", - "filter_people": "", - "find_them_fast": "", - "fix_incorrect_match": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "group_albums_by": "", - "has_quota": "", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", - "immich_logo": "", - "import_from_json": "", - "import_path": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", - "info": "", - "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" - }, - "invite_people": "", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", - "loop_videos_description": "", - "make": "", - "manage_shared_links": "", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", - "map_settings": "", - "matches": "", - "media_type": "", - "memories": "", - "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", - "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", - "no_duplicates_found": "", - "no_exif_info_available": "", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", - "note_apply_storage_label_to_previously_uploaded assets": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "offline_paths": "", - "offline_paths_description": "", - "ok": "", - "oldest_first": "", - "online": "", - "only_favorites": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner_can_access": "", - "partner_can_access_assets": "", - "partner_can_access_location": "", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "photos": "", - "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_deleted_assets": "", - "remove_from_album": "", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "removed_api_key": "", - "rename": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", - "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", - "scan_settings": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", - "search_your_photos": "", - "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_keep_all": "", - "select_library_owner": "", - "select_new_face": "", - "select_photos": "", - "select_trash_all": "", - "selected": "", - "send_message": "", - "send_welcome_email": "", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", - "settings": "", - "settings_saved": "", - "share": "", - "shared": "", - "shared_by": "", - "shared_by_you": "", - "shared_from_partner": "", - "shared_links": "", - "shared_with_partner": "", - "sharing": "", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_and_hide_people": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", - "show_metadata": "", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", - "sign_out": "", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", - "stack": "", - "stack_selected_photos": "", - "stacktrace": "", - "start": "", - "start_date": "", - "state": "", - "status": "", - "stop_motion_photo": "", - "stop_photo_sharing": "", - "stop_photo_sharing_description": "", - "stop_sharing_photos_with_user": "", - "storage": "", - "storage_label": "", - "storage_usage": "", - "submit": "", - "suggestions": "", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", - "sync": "", - "template": "", - "theme": "", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", - "timezone": "", - "to_archive": "", - "to_favorite": "", - "toggle_settings": "", - "toggle_theme": "", - "total_usage": "", - "trash": "", - "trash_all": "", - "trash_no_results_message": "", - "trashed_items_will_be_permanently_deleted_after": "", - "type": "", - "unarchive": "", - "unfavorite": "", - "unhide_person": "", - "unknown": "", - "unknown_year": "", - "unlimited": "", - "unlink_oauth": "", - "unlinked_oauth_account": "", - "unselect_all": "", - "unstack": "", - "untracked_files": "", - "untracked_files_decription": "", - "up_next": "", - "updated_password": "", - "upload": "", - "upload_concurrency": "", - "url": "", - "usage": "", - "user": "", - "user_id": "", - "user_usage_detail": "", - "username": "", - "users": "", - "utilities": "", - "validate": "", - "variables": "", - "version": "", - "video": "", - "video_hover_setting": "", - "video_hover_setting_description": "", - "videos": "", - "videos_count": "", - "view_all": "", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", - "waiting": "", - "week": "", - "welcome_to_immich": "", - "year": "", - "yes": "", - "you_dont_have_any_shared_links": "", - "zoom_image": "" + "add_more_users": "Putem mor man" } diff --git a/i18n/en.json b/i18n/en.json index beb27fa54a..b89da91860 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -564,6 +564,10 @@ "backup_options_page_title": "Backup options", "backup_setting_subtitle": "Manage background and foreground upload settings", "backward": "Backward", + "biometric_auth_enabled": "Biometric authentication enabled", + "biometric_locked_out": "You are locked out of biometric authentication", + "biometric_no_options": "No biometric options available", + "biometric_not_available": "Biometric authentication is not available on this device", "birthdate_saved": "Date of birth saved successfully", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", @@ -825,6 +829,7 @@ "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", "enable": "Enable", + "enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication", "enabled": "Enabled", "end_date": "End date", "enqueued": "Enqueued", @@ -998,6 +1003,7 @@ "external_network_sheet_info": "When not on the preferred Wi-Fi 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": "Unassigned", "failed": "Failed", + "failed_to_authenticate": "Failed to authenticate", "failed_to_load_assets": "Failed to load assets", "failed_to_load_folder": "Failed to load folder", "favorite": "Favorite", @@ -1064,6 +1070,8 @@ "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album so that the timeline can populate photos and videos in it", + "home_page_locked_error_local": "Can not move local assets to locked folder, skipping", + "home_page_locked_error_partner": "Can not move partner assets to locked folder, skipping", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "host": "Host", @@ -1231,8 +1239,6 @@ "memories_setting_description": "Manage what you see in your memories", "memories_start_over": "Start Over", "memories_swipe_to_close": "Swipe up to close", - "memories_year_ago": "A year ago", - "memories_years_ago": "{years, plural, other {# years}} ago", "memory": "Memory", "memory_lane_title": "Memory Lane {title}", "menu": "Menu", @@ -1404,6 +1410,7 @@ "play_memories": "Play memories", "play_motion_photo": "Play Motion Photo", "play_or_pause_video": "Play or pause video", + "please_auth_to_access": "Please authenticate to access", "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", @@ -1665,6 +1672,7 @@ "share_add_photos": "Add photos", "share_assets_selected": "{count} selected", "share_dialog_preparing": "Preparing...", + "share_link": "Share Link", "shared": "Shared", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activity_remove_content": "Do you want to delete this activity?", @@ -1888,6 +1896,7 @@ "uploading": "Uploading", "url": "URL", "usage": "Usage", + "use_biometric": "Use biometric", "use_current_connection": "use current connection", "use_custom_date_range": "Use custom date range instead", "user": "User", diff --git a/i18n/fa.json b/i18n/fa.json index ae9dc26b09..7708165132 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -65,8 +65,6 @@ "job_settings": "تنظیمات کار", "job_settings_description": "مدیریت همزمانی کار", "job_status": "وضعیت کار", - "jobs_delayed": "", - "jobs_failed": "", "library_created": "کتابخانه ایجاد شده: {library}", "library_deleted": "کتابخانه حذف شد", "library_import_path_description": "یک پوشه برای وارد کردن مشخص کنید. این پوشه، به همراه زیرپوشهها، برای یافتن تصاویر و ویدیوها اسکن خواهد شد.", @@ -128,7 +126,6 @@ "metadata_extraction_job": "استخراج فرا داده", "metadata_extraction_job_description": "استخراج اطلاعات ابرداده، مانند موقعیت جغرافیایی و کیفیت از هر فایل", "migration_job": "مهاجرت", - "migration_job_description": "", "no_paths_added": "هیچ مسیری اضافه نشده", "no_pattern_added": "هیچ الگوی اضافه نشده", "note_apply_storage_label_previous_assets": "توجه: برای اعمال برچسب ذخیره سازی به دارایی هایی که قبلاً بارگذاری شده اند، دستور زیر را اجرا کنید", @@ -178,8 +175,6 @@ "registration": "ثبت نام مدیر", "registration_description": "از آنجایی که شما اولین کاربر در سیستم هستید، به عنوان مدیر تعیین شدهاید و مسئولیت انجام وظایف مدیریتی بر عهده شما خواهد بود و کاربران اضافی توسط شما ایجاد خواهند شد.", "repair_all": "بازسازی همه", - "repair_matched_items": "", - "repaired_items": "", "require_password_change_on_login": "الزام کاربر به تغییر گذرواژه در اولین ورود", "reset_settings_to_default": "بازنشانی تنظیمات به حالت پیشفرض", "reset_settings_to_recent_saved": "بازنشانی تنظیمات به آخرین تنظیمات ذخیره شده", @@ -196,7 +191,6 @@ "smart_search_job_description": "اجرای یادگیری ماشینی بر روی داراییها برای پشتیبانی از جستجوی هوشمند", "storage_template_date_time_description": "زمانبندی ایجاد دارایی برای اطلاعات تاریخ و زمان استفاده میشود", "storage_template_date_time_sample": "نمونه زمان {date}", - "storage_template_enable_description": "", "storage_template_hash_verification_enabled": "تأیید هَش فعال شد", "storage_template_hash_verification_enabled_description": "تأیید هَش را فعال میکند؛ این گزینه را غیرفعال نکنید مگر اینکه از عواقب آن مطمئن باشید", "storage_template_migration": "انتقال الگوی ذخیره سازی", @@ -242,7 +236,6 @@ "transcoding_hardware_acceleration": "شتاب دهنده سخت افزاری", "transcoding_hardware_acceleration_description": "آزمایشی؛ بسیار سریعتر است، اما در همان بیتریت کیفیت کمتری خواهد داشت", "transcoding_hardware_decoding": "رمزگشایی سخت افزاری", - "transcoding_hardware_decoding_setting_description": "", "transcoding_hevc_codec": "کدک HEVC", "transcoding_max_b_frames": "بیشترین B-frames", "transcoding_max_b_frames_description": "مقادیر بالاتر کارایی فشرده سازی را بهبود میبخشند، اما کدگذاری را کند میکنند. ممکن است با شتاب دهی سختافزاری در دستگاههای قدیمی سازگار نباشد. مقدار( 0 ) B-frames را غیرفعال میکند، در حالی که مقدار ( 1 ) این مقدار را به صورت خودکار تنظیم میکند.", @@ -266,7 +259,6 @@ "transcoding_temporal_aq_description": "این مورد فقط برای NVENC اعمال می شود. افزایش کیفیت در صحنه های با جزئیات بالا و حرکت کم. ممکن است با دستگاه های قدیمی تر سازگار نباشد.", "transcoding_threads": "رشته ها ( موضوعات )", "transcoding_threads_description": "مقادیر بالاتر منجر به رمزگذاری سریع تر می شود، اما فضای کمتری برای پردازش سایر وظایف سرور در حین فعالیت باقی می گذارد. این مقدار نباید بیشتر از تعداد هسته های CPU باشد. اگر روی 0 تنظیم شود، بیشترین استفاده را خواهد داشت.", - "transcoding_tone_mapping": "", "transcoding_tone_mapping_description": "تلاش برای حفظ ظاهر ویدیوهای HDR هنگام تبدیل به SDR. هر الگوریتم تعادل های متفاوتی را برای رنگ، جزئیات و روشنایی ایجاد می کند. Hable جزئیات را حفظ می کند، Mobius رنگ را حفظ می کند و Reinhard روشنایی را حفظ می کند.", "transcoding_transcode_policy": "سیاست رمزگذاری", "transcoding_transcode_policy_description": "سیاست برای زمانی که ویدیویی باید مجددا تبدیل (رمزگذاری) شود. ویدیوهای HDR همیشه تبدیل (رمزگذاری) مجدد خواهند شد (مگر رمزگذاری مجدد غیرفعال باشد).", @@ -306,15 +298,12 @@ "administration": "مدیریت", "advanced": "پیشرفته", "album_added": "آلبوم اضافه شد", - "album_added_notification_setting_description": "", "album_cover_updated": "جلد آلبوم بهروزرسانی شد", "album_info_updated": "اطلاعات آلبوم بهروزرسانی شد", "album_name": "نام آلبوم", "album_options": "گزینههای آلبوم", "album_updated": "آلبوم بهروزرسانی شد", - "album_updated_setting_description": "", "albums": "آلبومها", - "albums_count": "", "all": "همه", "all_people": "همه افراد", "allow_dark_mode": "اجازه دادن به حالت تاریک", @@ -324,18 +313,13 @@ "app_settings": "تنظیمات برنامه", "appears_in": "ظاهر میشود در", "archive": "بایگانی", - "archive_or_unarchive_photo": "", "archive_size": "اندازه بایگانی", - "archive_size_description": "", "asset_offline": "محتوا آفلاین", "assets": "محتواها", "authorized_devices": "دستگاههای مجاز", "back": "بازگشت", "backward": "عقب", "blurred_background": "پسزمینه محو", - "bulk_delete_duplicates_confirmation": "", - "bulk_keep_duplicates_confirmation": "", - "bulk_trash_duplicates_confirmation": "", "camera": "دوربین", "camera_brand": "برند دوربین", "camera_model": "مدل دوربین", @@ -350,10 +334,8 @@ "change_name_successfully": "نام با موفقیت تغییر یافت", "change_password": "تغییر رمز عبور", "change_your_password": "رمز عبور خود را تغییر دهید", - "changed_visibility_successfully": "", "check_all": "انتخاب همه", "check_logs": "بررسی لاگها", - "choose_matching_people_to_merge": "", "city": "شهر", "clear": "پاک کردن", "clear_all": "پاک کردن همه", @@ -366,7 +348,6 @@ "comments_are_disabled": "نظرات غیرفعال هستند", "confirm": "تأیید", "confirm_admin_password": "تأیید رمز عبور مدیر", - "confirm_delete_shared_link": "", "confirm_password": "تأیید رمز عبور", "contain": "شامل", "context": "زمینه", @@ -393,8 +374,6 @@ "create_user": "ایجاد کاربر", "created": "ایجاد شد", "current_device": "دستگاه فعلی", - "custom_locale": "", - "custom_locale_description": "", "dark": "تاریک", "date_after": "تاریخ پس از", "date_and_time": "تاریخ و زمان", @@ -402,12 +381,8 @@ "date_range": "بازه زمانی", "day": "روز", "deduplicate_all": "حذف تکراریها به صورت کامل", - "default_locale": "", - "default_locale_description": "", "delete": "حذف", "delete_album": "حذف آلبوم", - "delete_api_key_prompt": "", - "delete_duplicates_confirmation": "", "delete_key": "حذف کلید", "delete_library": "حذف کتابخانه", "delete_link": "حذف لینک", @@ -425,14 +400,12 @@ "display_options": "گزینههای نمایش", "display_order": "ترتیب نمایش", "display_original_photos": "نمایش عکسهای اصلی", - "display_original_photos_setting_description": "", "done": "انجام شد", "download": "دانلود", "download_settings": "تنظیمات دانلود", "download_settings_description": "مدیریت تنظیمات مرتبط با دانلود محتوا", "downloading": "در حال دانلود", "duplicates": "تکراریها", - "duplicates_description": "", "duration": "مدت زمان", "edit_album": "ویرایش آلبوم", "edit_avatar": "ویرایش آواتار", @@ -440,8 +413,6 @@ "edit_date_and_time": "ویرایش تاریخ و زمان", "edit_exclusion_pattern": "ویرایش الگوی استثناء", "edit_faces": "ویرایش چهرهها", - "edit_import_path": "", - "edit_import_paths": "", "edit_key": "ویرایش کلید", "edit_link": "ویرایش لینک", "edit_location": "ویرایش مکان", @@ -456,73 +427,6 @@ "end_date": "تاریخ پایان", "error": "خطا", "error_loading_image": "خطا در بارگذاری تصویر", - "errors": { - "exclusion_pattern_already_exists": "", - "import_path_already_exists": "", - "paths_validation_failed": "", - "quota_higher_than_disk_size": "", - "repair_unable_to_check_items": "", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_exclusion_pattern": "", - "unable_to_add_import_path": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_exclusion_pattern": "", - "unable_to_delete_import_path": "", - "unable_to_delete_shared_link": "", - "unable_to_delete_user": "", - "unable_to_edit_exclusion_pattern": "", - "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_link_oauth_account": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_api_key": "", - "unable_to_remove_deleted_assets": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_api_key": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_timeline_display_status": "", - "unable_to_update_user": "" - }, "exit_slideshow": "خروج از نمایش اسلاید", "expand_all": "باز کردن همه", "expire_after": "منقضی شدن بعد از", @@ -534,15 +438,12 @@ "external": "خارجی", "external_libraries": "کتابخانههای خارجی", "favorite": "علاقهمندی", - "favorite_or_unfavorite_photo": "", "favorites": "علاقهمندیها", - "feature_photo_updated": "", "file_name": "نام فایل", "file_name_or_extension": "نام فایل یا پسوند", "filename": "نام فایل", "filetype": "نوع فایل", "filter_people": "فیلتر افراد", - "find_them_fast": "", "fix_incorrect_match": "رفع تطابق نادرست", "forward": "جلو", "general": "عمومی", @@ -562,19 +463,11 @@ "immich_web_interface": "رابط وب Immich", "import_from_json": "وارد کردن از JSON", "import_path": "مسیر وارد کردن", - "in_albums": "", "in_archive": "در بایگانی", "include_archived": "شامل بایگانی شدهها", "include_shared_albums": "شامل آلبومهای اشتراکی", - "include_shared_partner_assets": "", "individual_share": "اشتراک فردی", "info": "اطلاعات", - "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" - }, "invite_people": "دعوت افراد", "invite_to_album": "دعوت به آلبوم", "jobs": "وظایف", @@ -601,28 +494,22 @@ "login_has_been_disabled": "ورود غیرفعال شده است.", "look": "نگاه کردن", "loop_videos": "پخش مداوم ویدئوها", - "loop_videos_description": "", "make": "ساختن", "manage_shared_links": "مدیریت لینکهای اشتراکی", - "manage_sharing_with_partners": "", "manage_the_app_settings": "مدیریت تنظیمات برنامه", "manage_your_account": "مدیریت حساب کاربری شما", "manage_your_api_keys": "مدیریت کلیدهای API شما", "manage_your_devices": "مدیریت دستگاههای متصل", "manage_your_oauth_connection": "مدیریت اتصال OAuth شما", "map": "نقشه", - "map_marker_with_image": "", "map_settings": "تنظیمات نقشه", "matches": "تطابقها", "media_type": "نوع رسانه", "memories": "خاطرات", - "memories_setting_description": "", "memory": "خاطره", "menu": "منو", "merge": "ادغام", "merge_people": "ادغام افراد", - "merge_people_limit": "", - "merge_people_prompt": "", "merge_people_successfully": "ادغام افراد با موفقیت انجام شد", "minimize": "کوچک کردن", "minute": "دقیقه", @@ -643,20 +530,12 @@ "next": "بعدی", "next_memory": "خاطره بعدی", "no": "خیر", - "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", "no_duplicates_found": "هیچ تکراری یافت نشد.", "no_exif_info_available": "اطلاعات EXIF موجود نیست", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", "no_name": "بدون نام", "no_places": "مکانی یافت نشد", "no_results": "نتیجهای یافت نشد", - "no_shared_albums_message": "", "not_in_any_album": "در هیچ آلبومی نیست", - "note_apply_storage_label_to_previously_uploaded assets": "", "notes": "یادداشتها", "notification_toggle_setting_description": "اعلانهای ایمیلی را فعال کنید", "notifications": "اعلانها", @@ -664,7 +543,6 @@ "oauth": "OAuth", "offline": "آفلاین", "offline_paths": "مسیرهای آفلاین", - "offline_paths_description": "", "ok": "تأیید", "oldest_first": "قدیمیترین ابتدا", "online": "آنلاین", @@ -679,7 +557,6 @@ "owner": "مالک", "partner": "شریک", "partner_can_access": "{partner} میتواند دسترسی داشته باشد", - "partner_can_access_assets": "", "partner_can_access_location": "مکانهایی که عکسهای شما گرفته شدهاند", "partner_sharing": "اشتراکگذاری با شریک", "partners": "شرکا", @@ -687,11 +564,6 @@ "password_does_not_match": "رمز عبور مطابقت ندارد", "password_required": "رمز عبور مورد نیاز است", "password_reset_success": "بازنشانی رمز عبور موفقیتآمیز بود", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, "path": "مسیر", "pattern": "الگو", "pause": "توقف", @@ -699,14 +571,12 @@ "paused": "متوقف شده", "pending": "در انتظار", "people": "افراد", - "people_sidebar_description": "", "permanent_deletion_warning": "هشدار حذف دائمی", "permanent_deletion_warning_setting_description": "نمایش هشدار هنگام حذف دائمی محتواها", "permanently_delete": "حذف دائمی", "permanently_deleted_asset": "محتوای حذف شده دائمی", "person": "فرد", "photos": "عکسها", - "photos_count": "", "photos_from_previous_years": "عکسهای سالهای گذشته", "pick_a_location": "یک مکان انتخاب کنید", "place": "مکان", @@ -730,38 +600,27 @@ "recent_searches": "جستجوهای اخیر", "refresh": "تازه سازی", "refreshed": "تازه سازی شد", - "refreshes_every_file": "", "remove": "حذف", "remove_deleted_assets": "حذف محتواهای حذفشده", "remove_from_album": "حذف از آلبوم", "remove_from_favorites": "حذف از علاقهمندیها", - "remove_from_shared_link": "", - "removed_api_key": "", "rename": "تغییر نام", "repair": "تعمیر", - "repair_no_results_message": "", "replace_with_upload": "جایگزینی با آپلود", - "require_password": "", - "require_user_to_change_password_on_first_login": "", "reset": "بازنشانی", "reset_password": "بازنشانی رمز عبور", - "reset_people_visibility": "", - "resolved_all_duplicates": "", "restore": "بازیابی", "restore_all": "بازیابی همه", "restore_user": "بازیابی کاربر", "resume": "ادامه", - "retry_upload": "", "review_duplicates": "بررسی تکراریها", "role": "نقش", "save": "ذخیره", - "saved_api_key": "", "saved_profile": "پروفایل ذخیره شد", "saved_settings": "تنظیمات ذخیره شد", "say_something": "چیزی بگویید", "scan_all_libraries": "اسکن همه کتابخانهها", "scan_settings": "تنظیمات اسکن", - "scanning_for_album": "", "search": "جستجو", "search_albums": "جستجوی آلبومها", "search_by_context": "جستجو براساس زمینه", @@ -775,8 +634,6 @@ "search_state": "جستجوی ایالت...", "search_timezone": "جستجوی منطقه زمانی...", "search_type": "نوع جستجو", - "search_your_photos": "", - "searching_locales": "", "second": "ثانیه", "select_album_cover": "انتخاب جلد آلبوم", "select_all": "انتخاب همه", @@ -787,41 +644,28 @@ "select_library_owner": "انتخاب مالک کتابخانه", "select_new_face": "انتخاب چهره جدید", "select_photos": "انتخاب عکسها", - "select_trash_all": "", "selected": "انتخاب شده", "send_message": "ارسال پیام", "send_welcome_email": "ارسال ایمیل خوشآمدگویی", "server_stats": "آمار سرور", "set": "تنظیم", - "set_as_album_cover": "", - "set_as_profile_picture": "", "set_date_of_birth": "تنظیم تاریخ تولد", "set_profile_picture": "تنظیم تصویر پروفایل", - "set_slideshow_to_fullscreen": "", "settings": "تنظیمات", "settings_saved": "تنظیمات ذخیره شد", "share": "اشتراکگذاری", "shared": "مشترک", "shared_by": "مشترک توسط", - "shared_by_you": "", "shared_from_partner": "عکسها از {partner}", "shared_links": "لینکهای اشتراکی", - "shared_photos_and_videos_count": "", "shared_with_partner": "مشترک با {partner}", "sharing": "اشتراکگذاری", - "sharing_sidebar_description": "", "show_album_options": "نمایش گزینههای آلبوم", - "show_and_hide_people": "", "show_file_location": "نمایش مسیر فایل", "show_gallery": "نمایش گالری", "show_hidden_people": "نمایش افراد پنهان", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", "show_metadata": "نمایش اطلاعات متا", - "show_or_hide_info": "", "show_password": "نمایش رمز عبور", - "show_person_options": "", "show_progress_bar": "نمایش نوار پیشرفت", "show_search_options": "نمایش گزینههای جستجو", "shuffle": "تصادفی", @@ -831,60 +675,39 @@ "skip_to_content": "رفتن به محتوا", "slideshow": "نمایش اسلاید", "slideshow_settings": "تنظیمات نمایش اسلاید", - "sort_albums_by": "", "stack": "پشته", - "stack_selected_photos": "", - "stacktrace": "", "start": "شروع", "start_date": "تاریخ شروع", "state": "ایالت", "status": "وضعیت", "stop_motion_photo": "توقف عکس متحرک", - "stop_photo_sharing": "", - "stop_photo_sharing_description": "", - "stop_sharing_photos_with_user": "", "storage": "فضای ذخیرهسازی", "storage_label": "برچسب فضای ذخیرهسازی", - "storage_usage": "", "submit": "ارسال", "suggestions": "پیشنهادات", - "sunrise_on_the_beach": "", "swap_merge_direction": "تغییر جهت ادغام", "sync": "همگامسازی", "template": "الگو", "theme": "تم", "theme_selection": "انتخاب تم", - "theme_selection_description": "", - "time_based_memories": "", "timezone": "منطقه زمانی", "to_archive": "بایگانی", "to_favorite": "به علاقهمندیها", - "to_trash": "", "toggle_settings": "تغییر تنظیمات", "toggle_theme": "تغییر تم تاریک", "total_usage": "استفاده کلی", "trash": "سطل زباله", - "trash_all": "", - "trash_count": "", - "trash_no_results_message": "", - "trashed_items_will_be_permanently_deleted_after": "", "type": "نوع", - "unarchive": "", "unfavorite": "حذف از علاقهمندیها", "unhide_person": "آشکار کردن فرد", "unknown": "ناشناخته", "unknown_year": "سال نامشخص", "unlimited": "نامحدود", "unlink_oauth": "لغو اتصال OAuth", - "unlinked_oauth_account": "", "unnamed_album": "آلبوم بدون نام", "unnamed_share": "اشتراک بدون نام", "unselect_all": "لغو انتخاب همه", - "unstack": "", - "untracked_files": "", - "untracked_files_decription": "", "up_next": "مورد بعدی", - "updated_password": "", "upload": "آپلود", "upload_concurrency": "تعداد آپلود همزمان", "url": "آدرس", @@ -898,12 +721,8 @@ "validate": "اعتبارسنجی", "variables": "متغیرها", "version": "نسخه", - "version_announcement_message": "", "video": "ویدیو", - "video_hover_setting": "", - "video_hover_setting_description": "", "videos": "ویدیوها", - "videos_count": "", "view": "مشاهده", "view_all": "مشاهده همه", "view_all_users": "مشاهده همه کاربران", @@ -913,9 +732,7 @@ "waiting": "در انتظار", "week": "هفته", "welcome": "خوش آمدید", - "welcome_to_immich": "", "year": "سال", "yes": "بله", - "you_dont_have_any_shared_links": "", "zoom_image": "بزرگنمایی تصویر" } diff --git a/i18n/fil.json b/i18n/fil.json index 4b5ba5bb7b..9a802657dc 100644 --- a/i18n/fil.json +++ b/i18n/fil.json @@ -41,7 +41,6 @@ }, "album_user_left": "Umalis sa {album}", "all_albums": "Lahat ng albums", - "anti_clockwise": "", "api_key_description": "Isang beses lamang na ipapakita itong value. Siguraduhin na ikopya itong value bago iclose ang window na ito.", "are_these_the_same_person": "Itong tao na ito ay parehas?", "asset_adding_to_album": "Dinadagdag sa album...", diff --git a/i18n/hi.json b/i18n/hi.json index 8be95c4389..ab670944b5 100644 --- a/i18n/hi.json +++ b/i18n/hi.json @@ -689,7 +689,6 @@ "edit_title": "शीर्षक संपादित करें", "edit_user": "यूजर को संपादित करो", "edited": "संपादित", - "editor": "", "email": "ईमेल", "empty_folder": "This folder is empty", "empty_trash": "कूड़ेदान खाली करें", @@ -922,7 +921,6 @@ "info": "जानकारी", "interval": { "day_at_onepm": "हर दिन दोपहर 1 बजे", - "hours": "", "night_at_midnight": "हर रात आधी रात को", "night_at_twoam": "हर रात 2 बजे" }, @@ -1142,11 +1140,6 @@ "password_does_not_match": "पासवर्ड मैच नहीं कर रहा है", "password_required": "पासवर्ड आवश्यक", "password_reset_success": "पासवर्ड रीसेट सफल", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, "path": "पथ", "pattern": "नमूना", "pause": "विराम", diff --git a/i18n/hy.json b/i18n/hy.json index 6d6600439a..5e3615b0ac 100644 --- a/i18n/hy.json +++ b/i18n/hy.json @@ -1,784 +1,52 @@ { "about": "Մասին", - "account": "", - "account_settings": "", - "acknowledge": "", "action": "Գործողություն", - "actions": "", - "active": "", - "activity": "", "add": "Ավելացնել", - "add_a_description": "", "add_a_location": "Ավելացնել տեղ", "add_a_name": "Ավելացնել անուն", - "add_a_title": "", - "add_exclusion_pattern": "", - "add_import_path": "", "add_location": "Ավելացնել տեղ", - "add_more_users": "", - "add_partner": "", - "add_path": "", "add_photos": "Ավելացնել նկարներ", - "add_to": "", - "add_to_album": "", - "add_to_shared_album": "", - "admin": { - "add_exclusion_pattern_description": "", - "authentication_settings": "", - "authentication_settings_description": "", - "background_task_job": "", - "check_all": "", - "config_set_by_file": "", - "confirm_delete_library": "", - "confirm_delete_library_assets": "", - "confirm_email_below": "", - "confirm_reprocess_all_faces": "", - "confirm_user_password_reset": "", - "disable_login": "", - "duplicate_detection_job_description": "", - "exclusion_pattern_description": "", - "external_library_created_at": "", - "external_library_management": "", - "face_detection": "", - "face_detection_description": "", - "facial_recognition_job_description": "", - "force_delete_user_warning": "", - "forcing_refresh_library_files": "", - "image_format_description": "", - "image_prefer_embedded_preview": "", - "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", - "image_prefer_wide_gamut_setting_description": "", - "image_quality": "", - "image_settings": "", - "image_settings_description": "", - "job_concurrency": "", - "job_not_concurrency_safe": "", - "job_settings": "", - "job_settings_description": "", - "job_status": "", - "jobs_delayed": "", - "jobs_failed": "", - "library_created": "", - "library_deleted": "", - "library_import_path_description": "", - "library_scanning": "", - "library_scanning_description": "", - "library_scanning_enable_description": "", - "library_settings": "", - "library_settings_description": "", - "library_tasks_description": "", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", - "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", - "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", - "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", - "machine_learning_settings": "", - "machine_learning_settings_description": "", - "machine_learning_smart_search": "", - "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", - "manage_concurrency": "", - "manage_log_settings": "", - "map_dark_style": "", - "map_enable_description": "", - "map_light_style": "", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", - "map_settings": "", - "map_settings_description": "", - "map_style_description": "", - "metadata_extraction_job": "", - "metadata_extraction_job_description": "", - "migration_job": "", - "migration_job_description": "", - "no_paths_added": "", - "no_pattern_added": "", - "note_apply_storage_label_previous_assets": "", - "note_cannot_be_changed_later": "", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", - "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", - "notification_settings": "", - "notification_settings_description": "", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_enable_description": "", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_settings": "", - "oauth_settings_description": "", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", - "oauth_storage_quota_default_description": "", - "offline_paths": "", - "offline_paths_description": "", - "password_enable_description": "", - "password_settings": "", - "password_settings_description": "", - "paths_validated_successfully": "", - "quota_size_gib": "", - "refreshing_all_libraries": "", - "repair_all": "", - "repair_matched_items": "", - "repaired_items": "", - "require_password_change_on_login": "", - "reset_settings_to_default": "", - "reset_settings_to_recent_saved": "", - "send_welcome_email": "", - "server_external_domain_settings": "", - "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", - "server_welcome_message": "", - "server_welcome_message_description": "", - "sidecar_job": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "system_settings": "", - "theme_custom_css_settings": "", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "these_files_matched_by_checksum": "", - "thumbnail_generation_job": "", - "thumbnail_generation_job_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" - }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", - "album_added": "", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", - "album_updated_setting_description": "", - "albums": "", - "albums_count": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", "back": "Հետ", "backup_all": "Բոլոր", "backup_controller_page_background_battery_info_link": "Ցույց տուր ինչպես", "backup_controller_page_background_battery_info_ok": "Լավ", - "backward": "", - "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", - "change_date": "", - "change_expiration_time": "", "change_location": "Փոխել տեղը", "change_name": "Փոխել անուն", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", - "choose_matching_people_to_merge": "", "city": "Քաղաք", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", "client_cert_dialog_msg_confirm": "Լավ", - "close": "", - "collapse_all": "", "color": "Գույն", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", - "confirm_delete_shared_link": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", "control_bottom_app_bar_edit_location": "Փոխել Տեղը", - "copied_image_to_clipboard": "", - "copied_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", "country": "Երկիր", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", - "create_link": "", - "create_link_to_share": "", "create_new": "ՍՏԵՂԾԵԼ ՆՈՐ", "create_new_person": "Ստեղծել նոր անձ", - "create_new_user": "", "create_shared_album_page_share_select_photos": "Ընտրե Նկարներ", - "create_user": "", - "created": "", "curated_object_page_title": "Բաներ", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", "dark": "Մութ", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", "day": "Օր", - "default_locale": "", - "default_locale_description": "", "delete": "Ջնջել", - "delete_album": "", - "delete_api_key_prompt": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", - "delete_shared_link": "", - "delete_user": "", - "deleted_shared_link": "", - "description": "", - "details": "", - "direction": "", - "disabled": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "", - "done": "", - "download": "", - "downloading": "", - "duplicates": "", - "duration": "", - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", - "edit_link": "", "edit_location": "Փոխել տեղը", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", - "empty_trash": "", - "enable": "", - "enabled": "", - "end_date": "", - "error": "", - "error_loading_image": "", - "errors": { - "cleared_jobs": "", - "exclusion_pattern_already_exists": "", - "failed_job_command": "", - "import_path_already_exists": "", - "paths_validation_failed": "", - "quota_higher_than_disk_size": "", - "repair_unable_to_check_items": "", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_exclusion_pattern": "", - "unable_to_add_import_path": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_exclusion_pattern": "", - "unable_to_delete_import_path": "", - "unable_to_delete_shared_link": "", - "unable_to_delete_user": "", - "unable_to_edit_exclusion_pattern": "", - "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_link_oauth_account": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_api_key": "", - "unable_to_remove_deleted_assets": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_api_key": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_timeline_display_status": "", - "unable_to_update_user": "" - }, "exif_bottom_sheet_person_add_person": "Ավելացնել անուն", "exif_bottom_sheet_person_age": "Տարիք {}", "exif_bottom_sheet_person_age_years": "Տարիք {}", - "exit_slideshow": "", - "expand_all": "", - "expire_after": "", - "expired": "", - "explore": "", - "export": "", - "export_as_json": "", - "extension": "", - "external": "", - "external_libraries": "", - "favorite": "", - "favorite_or_unfavorite_photo": "", - "favorites": "", - "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", - "filter_people": "", - "find_them_fast": "", - "fix_incorrect_match": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "group_albums_by": "", - "has_quota": "", "hi_user": "Բարեւ {name} ({email})", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", - "immich_logo": "", - "immich_web_interface": "", - "import_from_json": "", - "import_path": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", - "info": "", - "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" - }, - "invite_people": "", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", - "loop_videos_description": "", - "make": "", - "manage_shared_links": "", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", "map_assets_in_bound": "{} նկար", "map_assets_in_bounds": "{} նկարներ", - "map_marker_with_image": "", - "map_settings": "", - "matches": "", - "media_type": "", - "memories": "", - "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", - "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", - "no_duplicates_found": "", - "no_exif_info_available": "", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", - "note_apply_storage_label_to_previously_uploaded assets": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "offline_paths": "", - "offline_paths_description": "", - "ok": "", - "oldest_first": "", - "online": "", - "only_favorites": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner_can_access": "", - "partner_can_access_assets": "", - "partner_can_access_location": "", "partner_list_user_photos": "{}-ին նկարները", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", "photos": "Նկարներ", - "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_deleted_assets": "", - "remove_from_album": "", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "removed_api_key": "", - "rename": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", "save": "Պահե", - "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", "scan_library": "Նայե", - "scan_settings": "", "search": "Փնտրե", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", "search_city": "Որոնե քաղաք…", - "search_country": "", "search_filter_date": "Ամսաթիվ", "search_filter_date_interval": "{start} մինչեւ {end}", "search_filter_location": "Տեղ", "search_filter_location_title": "Ընտրե տեղ", - "search_for_existing_person": "", "search_no_people": "Ոչ մի անձ", "search_page_motion_photos": "Շարժվող Նկարներ", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", - "search_your_photos": "", - "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_keep_all": "", - "select_library_owner": "", - "select_new_face": "", "select_photos": "Ընտրե նկարներ", - "select_trash_all": "", - "selected": "", - "send_message": "", - "send_welcome_email": "", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", "setting_notifications_notify_never": "երբեք", "setting_notifications_notify_seconds": "{} վայրկյան", - "settings": "", - "settings_saved": "", - "share": "", "share_add_photos": "Ավելացնել նկարներ", - "shared": "", - "shared_by": "", - "shared_by_you": "", - "shared_from_partner": "", "shared_link_edit_expire_after_option_day": "1 օր", "shared_link_edit_expire_after_option_days": "{} օր", "shared_link_edit_expire_after_option_hour": "1 ժամ", @@ -787,118 +55,20 @@ "shared_link_edit_expire_after_option_minutes": "{} րոպե", "shared_link_edit_expire_after_option_months": "{} ամիս", "shared_link_edit_expire_after_option_year": "{} տարի", - "shared_links": "", - "shared_photos_and_videos_count": "", - "shared_with_partner": "", - "sharing": "", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_and_hide_people": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", - "show_metadata": "", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", - "sign_out": "", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", "sort_oldest": "Ամենահին նկարը", "sort_recent": "Ամենանոր նկարը", - "stack": "", - "stack_selected_photos": "", - "stacktrace": "", - "start": "", - "start_date": "", - "state": "", - "status": "", - "stop_motion_photo": "", - "stop_photo_sharing": "", - "stop_photo_sharing_description": "", - "stop_sharing_photos_with_user": "", - "storage": "", - "storage_label": "", - "storage_usage": "", - "submit": "", - "suggestions": "", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", - "sync": "", - "template": "", - "theme": "", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", "timezone": "Ժամային գոտի", - "to_archive": "", - "to_favorite": "", "to_trash": "Աղբ", - "toggle_settings": "", - "toggle_theme": "", - "total_usage": "", "trash": "Աղբ", - "trash_all": "", - "trash_no_results_message": "", "trash_page_title": "Աղբ ({})", - "trashed_items_will_be_permanently_deleted_after": "", "type": "Տեսակ", - "unarchive": "", - "unfavorite": "", - "unhide_person": "", "unknown": "Անհայտ", "unknown_country": "Անհայտ Երկիր", "unknown_year": "Անհայտ Տարի", - "unlimited": "", - "unlink_oauth": "", - "unlinked_oauth_account": "", - "unselect_all": "", - "unstack": "", - "untracked_files": "", - "untracked_files_decription": "", - "up_next": "", - "updated_password": "", - "upload": "", - "upload_concurrency": "", "upload_status_errors": "Սխալներ", - "url": "", - "usage": "", - "user": "", - "user_id": "", - "user_usage_detail": "", - "username": "", - "users": "", - "utilities": "", - "validate": "", - "variables": "", - "version": "", "version_announcement_closing": "Քո ընկերը՝ Ալեքսը", - "video": "", - "video_hover_setting": "", - "video_hover_setting_description": "", - "videos": "", - "videos_count": "", - "view_all": "", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", - "waiting": "", "week": "Շաբաթ", "welcome": "Բարի գալուստ", - "welcome_to_immich": "", "year": "Տարի", - "yes": "Այո", - "you_dont_have_any_shared_links": "", - "zoom_image": "" + "yes": "Այո" } diff --git a/i18n/ka.json b/i18n/ka.json index ddd55ece2c..21b00bf950 100644 --- a/i18n/ka.json +++ b/i18n/ka.json @@ -14,7 +14,6 @@ "add_a_location": "დაამატე ადგილი", "add_a_name": "დაამატე სახელი", "add_a_title": "დაასათაურე", - "add_endpoint": "", "add_exclusion_pattern": "დაამატე გამონაკლისი ნიმუში", "add_import_path": "დაამატე საიმპორტო მისამართი", "add_location": "დაამატე ადგილი", diff --git a/i18n/kmr.json b/i18n/kmr.json index fc39e7b6cf..cf634e00da 100644 --- a/i18n/kmr.json +++ b/i18n/kmr.json @@ -2,868 +2,5 @@ "about": "دەربارە", "account": "هەژمار", "account_settings": "ڕێکخستنی هەژمار", - "acknowledge": "دانپێدانان", - "action": "", - "actions": "", - "active": "", - "activity": "", - "add": "", - "add_a_description": "", - "add_a_location": "", - "add_a_name": "", - "add_a_title": "", - "add_exclusion_pattern": "", - "add_import_path": "", - "add_location": "", - "add_more_users": "", - "add_partner": "", - "add_path": "", - "add_photos": "", - "add_to": "", - "add_to_album": "", - "add_to_shared_album": "", - "admin": { - "add_exclusion_pattern_description": "", - "authentication_settings": "", - "authentication_settings_description": "", - "background_task_job": "", - "check_all": "", - "config_set_by_file": "", - "confirm_delete_library": "", - "confirm_delete_library_assets": "", - "confirm_email_below": "", - "confirm_reprocess_all_faces": "", - "confirm_user_password_reset": "", - "disable_login": "", - "duplicate_detection_job_description": "", - "exclusion_pattern_description": "", - "external_library_created_at": "", - "external_library_management": "", - "face_detection": "", - "face_detection_description": "", - "facial_recognition_job_description": "", - "force_delete_user_warning": "", - "forcing_refresh_library_files": "", - "image_format_description": "", - "image_prefer_embedded_preview": "", - "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", - "image_prefer_wide_gamut_setting_description": "", - "image_quality": "", - "image_settings": "", - "image_settings_description": "", - "job_concurrency": "", - "job_not_concurrency_safe": "", - "job_settings": "", - "job_settings_description": "", - "job_status": "", - "jobs_delayed": "", - "jobs_failed": "", - "library_created": "", - "library_deleted": "", - "library_import_path_description": "", - "library_scanning": "", - "library_scanning_description": "", - "library_scanning_enable_description": "", - "library_settings": "", - "library_settings_description": "", - "library_tasks_description": "", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", - "machine_learning_duplicate_detection_enabled": "", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled": "", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", - "machine_learning_facial_recognition_setting": "", - "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", - "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", - "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", - "machine_learning_settings": "", - "machine_learning_settings_description": "", - "machine_learning_smart_search": "", - "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled": "", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", - "manage_concurrency": "", - "manage_log_settings": "", - "map_dark_style": "", - "map_enable_description": "", - "map_light_style": "", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", - "map_settings": "", - "map_settings_description": "", - "map_style_description": "", - "metadata_extraction_job": "", - "metadata_extraction_job_description": "", - "migration_job": "", - "migration_job_description": "", - "no_paths_added": "", - "no_pattern_added": "", - "note_apply_storage_label_previous_assets": "", - "note_cannot_be_changed_later": "", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", - "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", - "notification_settings": "", - "notification_settings_description": "", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_enable_description": "", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_settings": "", - "oauth_settings_description": "", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", - "oauth_storage_quota_default_description": "", - "offline_paths": "", - "offline_paths_description": "", - "password_enable_description": "", - "password_settings": "", - "password_settings_description": "", - "paths_validated_successfully": "", - "quota_size_gib": "", - "refreshing_all_libraries": "", - "repair_all": "", - "repair_matched_items": "", - "repaired_items": "", - "require_password_change_on_login": "", - "reset_settings_to_default": "", - "reset_settings_to_recent_saved": "", - "send_welcome_email": "", - "server_external_domain_settings": "", - "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", - "server_welcome_message": "", - "server_welcome_message_description": "", - "sidecar_job": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "system_settings": "", - "theme_custom_css_settings": "", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "these_files_matched_by_checksum": "", - "thumbnail_generation_job": "", - "thumbnail_generation_job_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" - }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", - "album_added": "", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", - "album_updated_setting_description": "", - "albums": "", - "albums_count": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", - "archive_size": "", - "archive_size_description": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", - "back": "", - "backward": "", - "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", - "change_date": "", - "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", - "choose_matching_people_to_merge": "", - "city": "", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", - "confirm_delete_shared_link": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", - "copied_image_to_clipboard": "", - "copied_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", - "create_link": "", - "create_link_to_share": "", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", - "day": "", - "default_locale": "", - "default_locale_description": "", - "delete": "", - "delete_album": "", - "delete_api_key_prompt": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", - "delete_shared_link": "", - "delete_user": "", - "deleted_shared_link": "", - "description": "", - "details": "", - "direction": "", - "disabled": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "", - "done": "", - "download": "", - "download_settings": "", - "download_settings_description": "", - "downloading": "", - "duplicates": "", - "duration": "", - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", - "edit_link": "", - "edit_location": "", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", - "empty_trash": "", - "end_date": "", - "error": "", - "error_loading_image": "", - "errors": { - "cleared_jobs": "", - "exclusion_pattern_already_exists": "", - "failed_job_command": "", - "import_path_already_exists": "", - "paths_validation_failed": "", - "quota_higher_than_disk_size": "", - "repair_unable_to_check_items": "", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_exclusion_pattern": "", - "unable_to_add_import_path": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_exclusion_pattern": "", - "unable_to_delete_import_path": "", - "unable_to_delete_shared_link": "", - "unable_to_delete_user": "", - "unable_to_edit_exclusion_pattern": "", - "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_link_oauth_account": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_api_key": "", - "unable_to_remove_deleted_assets": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_api_key": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_timeline_display_status": "", - "unable_to_update_user": "" - }, - "exit_slideshow": "", - "expand_all": "", - "expire_after": "", - "expired": "", - "explore": "", - "export": "", - "export_as_json": "", - "extension": "", - "external": "", - "external_libraries": "", - "favorite": "", - "favorite_or_unfavorite_photo": "", - "favorites": "", - "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", - "filter_people": "", - "find_them_fast": "", - "fix_incorrect_match": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "group_albums_by": "", - "has_quota": "", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", - "immich_logo": "", - "immich_web_interface": "", - "import_from_json": "", - "import_path": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", - "info": "", - "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" - }, - "invite_people": "", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", - "loop_videos_description": "", - "make": "", - "manage_shared_links": "", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", - "map_settings": "", - "matches": "", - "media_type": "", - "memories": "", - "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", - "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", - "no_duplicates_found": "", - "no_exif_info_available": "", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", - "note_apply_storage_label_to_previously_uploaded assets": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "offline_paths": "", - "offline_paths_description": "", - "ok": "", - "oldest_first": "", - "online": "", - "only_favorites": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner_can_access": "", - "partner_can_access_assets": "", - "partner_can_access_location": "", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "photos": "", - "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_deleted_assets": "", - "remove_from_album": "", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "removed_api_key": "", - "rename": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", - "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", - "scan_settings": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", - "search_your_photos": "", - "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_keep_all": "", - "select_library_owner": "", - "select_new_face": "", - "select_photos": "", - "select_trash_all": "", - "selected": "", - "send_message": "", - "send_welcome_email": "", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", - "settings": "", - "settings_saved": "", - "share": "", - "shared": "", - "shared_by": "", - "shared_by_you": "", - "shared_from_partner": "", - "shared_links": "", - "shared_photos_and_videos_count": "", - "shared_with_partner": "", - "sharing": "", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_and_hide_people": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", - "show_metadata": "", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", - "sign_out": "", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", - "stack": "", - "stack_selected_photos": "", - "stacktrace": "", - "start": "", - "start_date": "", - "state": "", - "status": "", - "stop_motion_photo": "", - "stop_photo_sharing": "", - "stop_photo_sharing_description": "", - "stop_sharing_photos_with_user": "", - "storage": "", - "storage_label": "", - "storage_usage": "", - "submit": "", - "suggestions": "", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", - "sync": "", - "template": "", - "theme": "", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", - "timezone": "", - "to_archive": "", - "to_favorite": "", - "toggle_settings": "", - "toggle_theme": "", - "total_usage": "", - "trash": "", - "trash_all": "", - "trash_no_results_message": "", - "trashed_items_will_be_permanently_deleted_after": "", - "type": "", - "unarchive": "", - "unfavorite": "", - "unhide_person": "", - "unknown": "", - "unknown_year": "", - "unlimited": "", - "unlink_oauth": "", - "unlinked_oauth_account": "", - "unselect_all": "", - "unstack": "", - "untracked_files": "", - "untracked_files_decription": "", - "up_next": "", - "updated_password": "", - "upload": "", - "upload_concurrency": "", - "url": "", - "usage": "", - "user": "", - "user_id": "", - "user_usage_detail": "", - "username": "", - "users": "", - "utilities": "", - "validate": "", - "variables": "", - "version": "", - "video": "", - "video_hover_setting": "", - "video_hover_setting_description": "", - "videos": "", - "videos_count": "", - "view_all": "", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", - "waiting": "", - "week": "", - "welcome": "", - "welcome_to_immich": "", - "year": "", - "yes": "", - "you_dont_have_any_shared_links": "", - "zoom_image": "" + "acknowledge": "دانپێدانان" } diff --git a/i18n/lt.json b/i18n/lt.json index 4a3973e5c8..3a4fa44269 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -71,9 +71,7 @@ "image_format": "Formatas", "image_format_description": "WebP sukuria mažesnius failus nei JPEG, bet lėčiau juos apdoroja.", "image_prefer_embedded_preview": "Pageidautinai rodyti įterptą peržiūrą", - "image_prefer_embedded_preview_setting_description": "", "image_prefer_wide_gamut": "Teikti pirmenybę plačiai gamai", - "image_prefer_wide_gamut_setting_description": "", "image_preview_description": "Vidutinio dydžio vaizdas su išvalytais metaduomenimis, naudojamas kai žiūrimas vienas objektas arba mašininiam mokymuisi", "image_preview_quality_description": "Peržiūros kokybė nuo 1-100. Aukštesnės reikšmės yra geriau, bet sukuriami didesni failai gali sumažinti programos reagavimo laiką. Mažos vertės nustatymas gali paveikti mašininio mokymo kokybę.", "image_preview_title": "Peržiūros nustatymai", @@ -109,22 +107,18 @@ "machine_learning_clip_model": "CLIP modelis", "machine_learning_duplicate_detection": "Dublikatų aptikimas", "machine_learning_duplicate_detection_enabled": "Įjungti dublikatų aptikimą", - "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "Naudoti CLIP įterpimus, norint rasti galimus duplikatus", "machine_learning_enabled": "Įgalinti mašininį mokymąsi", "machine_learning_enabled_description": "Jei išjungta, visos „ML“ funkcijos bus išjungtos, nepaisant toliau pateiktų nustatymų.", "machine_learning_facial_recognition": "Veidų atpažinimas", "machine_learning_facial_recognition_description": "Aptikti, atpažinti ir sugrupuoti veidus nuotraukose", "machine_learning_facial_recognition_model": "Veidų atpažinimo modelis", - "machine_learning_facial_recognition_model_description": "", "machine_learning_facial_recognition_setting": "Įgalinti veidų atpažinimą", "machine_learning_facial_recognition_setting_description": "Išjungus, vaizdai nebus užšifruoti veidų atpažinimui ir nebus naudojami Žmonių sekcijoje Naršymo puslapyje.", "machine_learning_max_detection_distance": "Maksimalus aptikimo atstumas", "machine_learning_max_detection_distance_description": "Didžiausias atstumas tarp dviejų vaizdų, kad jie būtų laikomi dublikatais, svyruoja nuo 0,001 iki 0,1. Didesnės vertės aptiks daugiau dublikatų, tačiau gali būti klaidingai teigiami.", "machine_learning_max_recognition_distance": "Maksimalus atpažinimo atstumas", - "machine_learning_max_recognition_distance_description": "", "machine_learning_min_detection_score": "Minimalus aptikimo balas", - "machine_learning_min_detection_score_description": "", "machine_learning_min_recognized_faces": "Mažiausias atpažintų veidų skaičius", "machine_learning_min_recognized_faces_description": "Mažiausias atpažintų veidų skaičius asmeniui, kurį reikia sukurti. Tai padidinus, veido atpažinimas tampa tikslesnis, bet padidėja tikimybė, kad veidas žmogui nepriskirtas.", "machine_learning_settings": "Mašininio mokymosi nustatymai", @@ -158,7 +152,6 @@ "metadata_settings": "Metaduomenų nustatymai", "metadata_settings_description": "Tvarkyti metaduomenų nustatymus", "migration_job": "Migracija", - "migration_job_description": "", "no_paths_added": "Keliai nepridėti", "no_pattern_added": "Šablonas nepridėtas", "note_apply_storage_label_previous_assets": "Pastaba: norėdami pritaikyti saugyklos etiketę seniau įkeltiems ištekliams, paleiskite", @@ -191,12 +184,6 @@ "oauth_settings": "OAuth", "oauth_settings_description": "Tvarkyti OAuth prisijungimo nustatymus", "oauth_settings_more_details": "Detaliau apie šią funkciją galite paskaityti <link>dokumentacijoje</link>.", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", - "oauth_storage_quota_default_description": "", "offline_paths": "Nepasiekiami adresai", "offline_paths_description": "Šie rezultatai gali būti dėl rankinio failų ištrynimo, kurie nėra išorinės bibliotekos dalis.", "password_enable_description": "Prisijungti su el. paštu ir slaptažodžiu", @@ -217,93 +204,42 @@ "server_settings_description": "Tvarkyti serverio nustatymus", "server_welcome_message": "Sveikinimo pranešimas", "server_welcome_message_description": "Žinutė, rodoma prisijungimo puslapyje.", - "sidecar_job_description": "", "slideshow_duration_description": "Sekundžių skaičius, kiek viena nuotrauka rodoma", "smart_search_job_description": "Vykdykite mašininį mokymąsi bibliotekos elementų išmaniajai paieškai", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", "system_settings": "Sistemos nustatymai", "tag_cleanup_job": "Žymų išvalymas", "theme_custom_css_settings": "Individualizuotas CSS", - "theme_custom_css_settings_description": "", "theme_settings": "Temos nustatymai", - "theme_settings_description": "", "thumbnail_generation_job": "Generuoti miniatiūras", "thumbnail_generation_job_description": "Didelių, mažų ir neryškių miniatiūrų generavimas kiekvienam bibliotekos elementui, taip pat miniatiūrų generavimas kiekvienam asmeniui", "transcoding_acceleration_api": "Spartinimo API", - "transcoding_acceleration_api_description": "", "transcoding_acceleration_nvenc": "NVENC (reikalinga NVIDIA GPU)", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", "transcoding_accepted_containers": "Priimami konteineriai", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", "transcoding_advanced_options_description": "Parinktys, kurių daugelis naudotojų keisti neturėtų", "transcoding_audio_codec": "Garso kodekas", "transcoding_audio_codec_description": "Opus yra aukščiausios kokybės variantas, tačiau turi mažesnį suderinamumą su senesniais įrenginiais ar programine įranga.", "transcoding_bitrate_description": "Vaizdo įrašai viršija maksimalią leistiną bitų spartą arba nėra priimtino formato", "transcoding_constant_quality_mode": "Pastovios kokybės režimas", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", "transcoding_hardware_acceleration": "Techninės įrangos spartinimas", - "transcoding_hardware_acceleration_description": "", "transcoding_hardware_decoding": "Aparatinis dekodavimas", - "transcoding_hardware_decoding_setting_description": "", "transcoding_hevc_codec": "HEVC kodekas", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", "transcoding_max_bitrate": "Maksimalus bitų srautas", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", "transcoding_target_resolution_description": "Didesnės skiriamosios gebos gali išsaugoti daugiau detalių, tačiau jas koduoti užtrunka ilgiau, failų dydžiai yra didesni ir gali sumažėti programos jautrumas.", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", "transcoding_video_codec": "Video kodekas", - "transcoding_video_codec_description": "", "trash_enabled_description": "Įgalinti šiukšliadėžės funkcijas", "trash_number_of_days": "Dienų skaičius", - "trash_number_of_days_description": "", "trash_settings": "Šiukšliadėžės nustatymai", "trash_settings_description": "Tvarkyti šiukšliadėžės nustatymus", "untracked_files": "Nesekami failai", "untracked_files_description": "Šie failai aplikacijos nesekami. Jie galėjo atsirasti dėl nepavykusio perkėlimo, nutraukto įkėlimo ar palikti per klaidą", "user_delete_delay_settings": "Ištrynimo delsa", - "user_delete_delay_settings_description": "", "user_management": "Naudotojų valdymas", "user_password_has_been_reset": "Naudotojo slaptažodis buvo iš naujo nustatytas:", "user_restore_description": "Naudotojo <b>{user}</b> paskyra bus atkurta.", "user_settings": "Naudotojo nustatymai", "user_settings_description": "Valdyti naudotojo nustatymus", "user_successfully_removed": "Naudotojas {email} sėkmingai pašalintas.", - "version_check_enabled_description": "", "version_check_settings": "Versijos tikrinimas", "version_check_settings_description": "Įjungti/išjungti naujos versijos pranešimus", "video_conversion_job": "Vaizdo įrašų konvertavimas", @@ -312,7 +248,6 @@ "admin_email": "Administratoriaus el. paštas", "admin_password": "Administratoriaus slaptažodis", "administration": "Administravimas", - "advanced": "", "advanced_settings_log_level_title": "Log level: {}", "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", "advanced_settings_prefer_remote_title": "Prefer remote images", @@ -370,7 +305,6 @@ "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "app_settings": "Programos nustatymai", - "appears_in": "", "archive": "Archyvas", "archive_or_unarchive_photo": "Archyvuoti arba išarchyvuoti nuotrauką", "archive_page_no_archived_assets": "No archived assets found", @@ -488,7 +422,6 @@ "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", "backup_setting_subtitle": "Manage background and foreground upload settings", - "backward": "", "birthdate_saved": "Sėkmingai išsaugota gimimo data", "blurred_background": "Neryškus fonas", "bugs_and_feature_requests": "Klaidų ir funkcijų užklausos", @@ -527,7 +460,6 @@ "change_expiration_time": "Pakeisti galiojimo trukmę", "change_location": "Pakeisti vietovę", "change_name": "Pakeisti vardą", - "change_name_successfully": "", "change_password": "Pakeisti slaptažodį", "change_password_description": "Tai arba pirmas kartas, kai jungiatės prie sistemos, arba buvo pateikta užklausa pakeisti jūsų slaptažodį. Prašome įvesti naują slaptažodį žemiau.", "change_password_form_confirm_password": "Confirm Password", @@ -570,7 +502,6 @@ "confirm_admin_password": "Patvirtinti administratoriaus slaptažodį", "confirm_delete_shared_link": "Ar tikrai norite ištrinti šią bendrinimo nuorodą?", "confirm_password": "Patvirtinti slaptažodį", - "contain": "", "context": "Kontekstas", "continue": "Tęsti", "control_bottom_app_bar_album_info_shared": "{} items · Shared", @@ -592,8 +523,6 @@ "copy_password": "Kopijuoti slaptažodį", "copy_to_clipboard": "Kopijuoti į iškarpinę", "country": "Šalis", - "cover": "", - "covers": "", "create": "Sukurti", "create_album": "Sukurti albumą", "create_album_page_untitled": "Untitled", @@ -615,24 +544,20 @@ "curated_object_page_title": "Things", "current_device": "Dabartinis įrenginys", "current_server_address": "Current server address", - "custom_locale": "", "custom_locale_description": "Formatuoti datas ir skaičius pagal kalbą ir regioną", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", - "dark": "", "date_after": "Data po", "date_and_time": "Data ir laikas", "date_before": "Data prieš", "date_format": "E, LLL d, y • h:mm a", "date_of_birth_saved": "Gimimo data sėkmingai išsaugota", - "date_range": "", "day": "Diena", "deduplicate_all": "Šalinti visus dublikatus", "deduplication_criteria_1": "Failo dydis baitais", "deduplication_criteria_2": "EXIF metaduomenų įrašų skaičius", "deduplication_info": "Dublikatų šalinimo informacija", "deduplication_info_description": "Automatinis elementų parinkimas ir masinis dublikatų šalinimas atliekamas atsižvelgiant į:", - "default_locale": "", "default_locale_description": "Formatuoti datas ir skaičius pagal jūsų naršyklės lokalę", "delete": "Ištrinti", "delete_album": "Ištrinti albumą", @@ -665,13 +590,10 @@ "discover": "Atrasti", "dismiss_all_errors": "Nepaisyti visų klaidų", "dismiss_error": "Nepaisyti klaidos", - "display_options": "", "display_order": "Atvaizdavimo tvarka", "display_original_photos": "Rodyti originalias nuotraukas", - "display_original_photos_setting_description": "", "do_not_show_again": "Daugiau nerodyti šio pranešimo", "documentation": "Dokumentacija", - "done": "", "download": "Atsisiųsti", "download_canceled": "Download canceled", "download_complete": "Download complete", @@ -711,7 +633,6 @@ "edit_title": "Redaguoti antraštę", "edit_user": "Redaguoti naudotoją", "edited": "Redaguota", - "editor": "", "email": "El. paštas", "empty_folder": "This folder is empty", "empty_trash": "Ištuštinti šiukšliadėžę", @@ -763,56 +684,37 @@ "unable_to_create_library": "Nepavyko sukurti bibliotekos", "unable_to_create_user": "Nepavyko sukurti naudotojo", "unable_to_delete_album": "Nepavyksta ištrinti albumo", - "unable_to_delete_asset": "", "unable_to_delete_exclusion_pattern": "Nepavyksta ištrinti išimčių šablono", "unable_to_delete_import_path": "Nepavyksta ištrinti importavimo kelio", "unable_to_delete_shared_link": "Nepavyko ištrinti bendrinimo nuorodos", "unable_to_delete_user": "Nepavyksta ištrinti naudotojo", "unable_to_edit_exclusion_pattern": "Nepavyksta redaguoti išimčių šablono", "unable_to_edit_import_path": "Nepavyksta redaguoti išimčių kelio", - "unable_to_empty_trash": "", "unable_to_enter_fullscreen": "Nepavyksta pereiti į viso ekrano režimą", "unable_to_exit_fullscreen": "Nepavyksta išeiti iš viso ekrano režimo", "unable_to_get_shared_link": "Nepavyko gauti bendrinimo nuorodos", "unable_to_hide_person": "Nepavyksta paslėpti žmogaus", "unable_to_link_oauth_account": "Nepavyko susieti su OAuth paskyra", "unable_to_load_album": "Nepavyksta užkrauti albumo", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", "unable_to_log_out_all_devices": "Nepavyksta atjungti visų įrenginių", "unable_to_log_out_device": "Nepavyksta atjungti įrenginio", "unable_to_login_with_oauth": "Nepavyko prisijungti su OAuth", "unable_to_play_video": "Nepavyksta paleisti vaizdo įrašo", "unable_to_refresh_user": "Nepavyksta atnaujinti naudotojo", - "unable_to_remove_album_users": "", "unable_to_remove_api_key": "Nepavyko pašalinti API rakto", "unable_to_remove_assets_from_shared_link": "Nepavyko iš bendrinimo nuorodos pašalinti elementų", "unable_to_remove_deleted_assets": "Nepavyko pašalinti nepasiekiamų elementų", "unable_to_remove_library": "Nepavyksta pašalinti bibliotekos", "unable_to_remove_partner": "Nepavyksta pašalinti partnerio", "unable_to_remove_reaction": "Nepavyksta pašalinti reakcijos", - "unable_to_repair_items": "", - "unable_to_reset_password": "", "unable_to_resolve_duplicate": "Nepavyko sutvarkyti dublikatų", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", "unable_to_save_profile": "Nepavyko išsaugoti profilio", "unable_to_save_settings": "Nepavyksta išsaugoti nustatymų", "unable_to_scan_libraries": "Nepavyksta nuskaityti bibliotekų", "unable_to_scan_library": "Nepavyksta nuskaityti bibliotekos", "unable_to_set_feature_photo": "Nepavyksta nustatyti mėgstamiausios nuotraukos", "unable_to_set_profile_picture": "Nepavyksta nustatyti profilio nuotraukos", - "unable_to_submit_job": "", "unable_to_trash_asset": "Nepavyko perkelti į šiukšliadėžę", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_user": "", "unable_to_upload_file": "Nepavyksta įkelti failo" }, "exif": "Exif", @@ -831,7 +733,6 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", - "expire_after": "", "expired": "Nebegalioja", "expires_date": "Nebegalios už {date}", "explore": "Naršyti", @@ -850,27 +751,19 @@ "favorite_or_unfavorite_photo": "Įtraukti prie arba pašalinti iš mėgstamiausių", "favorites": "Mėgstamiausi", "favorites_page_no_favorites": "No favorite assets found", - "feature_photo_updated": "", "features": "Funkcijos", "features_setting_description": "Valdyti aplikacijos funkcijas", "file_name": "Failo pavadinimas", "file_name_or_extension": "Failo pavadinimas arba plėtinys", - "filename": "", "filetype": "Failo tipas", "filter": "Filter", "filter_people": "Filtruoti žmones", - "fix_incorrect_match": "", "folder": "Folder", "folder_not_found": "Folder not found", "folders": "Aplankai", "folders_feature_description": "Peržiūrėkite failų sistemoje esančias nuotraukas ir vaizdo įrašus aplankų rodinyje", - "forward": "", - "general": "", "get_help": "Gauti pagalbos", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "getting_started": "", - "go_back": "", - "go_to_search": "", "grant_permission": "Grant permission", "group_albums_by": "Grupuoti albumus pagal...", "group_no": "Negrupuoti", @@ -906,7 +799,6 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", - "host": "", "hour": "Valanda", "ignore_icloud_photos": "Ignore iCloud photos", "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", @@ -922,11 +814,9 @@ "include_archived": "Įtraukti archyvuotus", "include_shared_albums": "Įtraukti bendrinamus albumus", "include_shared_partner_assets": "Įtraukti partnerio pasidalintus elementus", - "individual_share": "", "info": "Informacija", "interval": { "day_at_onepm": "Kiekvieną dieną 13:00", - "hours": "", "night_at_midnight": "Kiekvieną vidurnaktį", "night_at_twoam": "Kiekvieną naktį 02:00" }, @@ -955,7 +845,6 @@ "library_page_sort_created": "Created date", "library_page_sort_last_modified": "Last modified", "library_page_sort_title": "Album title", - "light": "", "link_options": "Nuorodų parinktys", "link_to_oauth": "Susieti su OAuth", "linked_oauth_account": "Susieta OAuth paskyra", @@ -1000,9 +889,7 @@ "logout_all_device_confirmation": "Ar tikrai norite atsijungti iš visų įrenginių?", "logout_this_device_confirmation": "Ar tikrai norite atsijungti iš šio prietaiso?", "longitude": "Ilguma", - "look": "", "loop_videos": "Kartoti vaizdo įrašus", - "loop_videos_description": "", "make": "Gamintojas", "manage_shared_links": "Bendrinimo nuorodų tvarkymas", "manage_sharing_with_partners": "Valdyti dalijimąsi su partneriais", @@ -1019,7 +906,6 @@ "map_location_picker_page_use_location": "Use this location", "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", "map_location_service_disabled_title": "Location Service disabled", - "map_marker_with_image": "", "map_no_assets_in_bounds": "No photos in this area", "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", "map_no_location_permission_title": "Location Permission denied", @@ -1086,15 +972,11 @@ "no_assets_message": "SPUSTELĖKITE NORĖDAMI ĮKELTI PIRMĄJĄ NUOTRAUKĄ", "no_assets_to_show": "No assets to show", "no_duplicates_found": "Dublikatų nerasta.", - "no_exif_info_available": "", "no_explore_results_message": "Įkelkite daugiau nuotraukų ir tyrinėkite savo kolekciją.", - "no_favorites_message": "", "no_libraries_message": "Sukurkite išorinę biblioteką nuotraukoms ir vaizdo įrašams peržiūrėti", "no_name": "Be vardo", - "no_places": "", "no_results": "Nerasta", "no_results_description": "Pabandykite sinonimą arba bendresnį raktažodį", - "no_shared_albums_message": "", "not_in_any_album": "Nė viename albume", "not_selected": "Not selected", "notes": "Pastabos", @@ -1120,7 +1002,6 @@ "or": "arba", "organize_your_library": "Tvarkykite savo biblioteką", "original": "Originalas", - "other": "", "other_devices": "Kiti įrenginiai", "other_variables": "Kiti kintamieji", "owned": "Nuosavi", @@ -1137,19 +1018,12 @@ "partner_page_select_partner": "Select partner", "partner_page_shared_to_title": "Shared to", "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_sharing": "", "partners": "Partneriai", "password": "Slaptažodis", "password_does_not_match": "Slaptažodis nesutampa", "password_required": "Reikalingas slaptažodis", "password_reset_success": "Slaptažodis sėkmingai atkurtas", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, "path": "Kelias", - "pattern": "", "pause": "Sustabdyti", "pause_memories": "Pristabdyti atsiminimus", "paused": "Sustabdyta", @@ -1158,11 +1032,8 @@ "people_edits_count": "{count, plural, one {Redaguotas # asmuo} few {Redaguoti # asmenys} other {Redaguota # asmenų}}", "people_feature_description": "Peržiūrėkite nuotraukas ir vaizdo įrašus sugrupuotus pagal asmenis", "people_sidebar_description": "Rodyti asmenų rodinio nuorodą šoninėje juostoje", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", "permanently_delete": "Ištrinti visam laikui", "permanently_delete_assets_count": "Visam laikui ištrinti {count, plural, one {# elementą} few {# elementus} other {# elementų}}", - "permanently_deleted_asset": "", "permanently_deleted_assets_count": "Visam laikui {count, plural, one {ištrintas # elementas} few {ištrinti # elementai} other {ištrinta # elementų}}", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", @@ -1176,22 +1047,11 @@ "photos_and_videos": "Nuotraukos ir vaizdo įrašai", "photos_count": "{count, plural, one {{count, number} nuotrauka} few {{count, number} nuotraukos} other {{count, number} nuotraukų}}", "photos_from_previous_years": "Ankstesnių metų nuotraukos", - "pick_a_location": "", "place": "Vieta", "places": "Vietos", - "play": "", "play_memories": "Leisti atsiminimus", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", @@ -1202,7 +1062,6 @@ "profile_image_of_user": "{user} profilio nuotrauka", "profile_picture_set": "Profilio nuotrauka nustatyta.", "public_album": "Viešas albumas", - "public_share": "", "purchase_account_info": "Rėmėjas", "purchase_activated_subtitle": "Dėkojame, kad remiate Immich ir atviro kodo programinę įrangą", "purchase_activated_time": "Suaktyvinta {date}", @@ -1237,10 +1096,6 @@ "rating": "Įvertinimas žvaigždutėmis", "rating_count": "{count, plural, one {# įvertinimas} few {# įvertinimai} other {# įvertinimų}}", "rating_description": "Rodyti EXIF įvertinimus informacijos skydelyje", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "refresh": "Atnaujinti", @@ -1255,7 +1110,6 @@ "refreshing_metadata": "Perkraunami metaduomenys", "remove": "Pašalinti", "remove_assets_shared_link_confirmation": "Ar tikrai norite pašalinti {count, plural, one {# elementą} few {# elementus} other {# elementų}} iš šios bendrinimo nuorodos?", - "remove_deleted_assets": "", "remove_from_album": "Pašalinti iš albumo", "remove_from_favorites": "Pašalinti iš mėgstamiausių", "remove_from_shared_link": "Pašalinti iš bendrinimo nuorodos", @@ -1273,16 +1127,12 @@ "replace_with_upload": "Pakeisti naujai įkeltu failu", "require_password": "Reikalauti slaptažodžio", "reset": "Atstatyti", - "reset_password": "", - "reset_people_visibility": "", "resolve_duplicates": "Sutvarkyti dublikatus", "resolved_all_duplicates": "Sutvarkyti visi dublikatai", "restore": "Atkurti", "restore_all": "Atkurti visus", "restore_user": "Atkurti naudotoją", - "retry_upload": "", "review_duplicates": "Peržiūrėti dublikatus", - "role": "", "save": "Išsaugoti", "save_to_gallery": "Save to gallery", "saved_api_key": "Išsaugotas API raktas", @@ -1294,14 +1144,10 @@ "scan_library": "Skenuoti", "scan_settings": "Skenavimo nustatymai", "search": "Ieškoti", - "search_albums": "", "search_by_context": "Ieškoti pagal kontekstą", "search_by_description_example": "Žygio diena Sapoje", "search_by_filename": "Ieškoti pagal failo pavadinimą arba plėtinį", "search_by_filename_example": "pvz. IMG_1234.JPG arba PNG", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", "search_country": "Ieškoti šalies...", "search_filter_apply": "Apply filter", "search_filter_camera_title": "Select camera type", @@ -1316,7 +1162,6 @@ "search_filter_media_type": "Media Type", "search_filter_media_type_title": "Select media type", "search_filter_people_title": "Select people", - "search_for_existing_person": "", "search_no_more_result": "No more results", "search_no_people_named": "Nėra žmonių vardu „{name}“", "search_no_result": "No results found, try a different search term or combination", @@ -1335,25 +1180,17 @@ "search_places": "Ieškoti vietų", "search_result_page_new_search_hint": "New Search", "search_settings": "Ieškoti nustatymų", - "search_state": "", "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", "search_suggestion_list_smart_search_hint_2": "m:your-search-term", "search_tags": "Ieškoti žymų...", - "search_timezone": "", "search_type": "Paieškos tipas", "search_your_photos": "Ieškoti nuotraukų", - "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", "select_all_duplicates": "Pasirinkti visus dublikatus", "select_avatar_color": "Pasirinkti avataro spalvą", "select_face": "Pasirinkti veidą", "select_featured_photo": "Pasirinkti rodomą nuotrauką", "select_keep_all": "Visus pažymėti \"Palikti\"", "select_library_owner": "Pasirinkti bibliotekos savininką", - "select_new_face": "", - "select_photos": "", "select_trash_all": "Visus pažymėti \"Išmesti\"", "select_user_for_sharing_page_err_album": "Failed to create album", "selected": "Pasirinkta", @@ -1368,7 +1205,6 @@ "server_stats": "Serverio statistika", "server_version": "Serverio versija", "set": "Nustatyti", - "set_as_album_cover": "", "set_as_profile_picture": "Nustatyti kaip profilio nuotrauką", "set_date_of_birth": "Nustatyti gimimo datą", "set_profile_picture": "Nustatyti profilio nuotrauką", @@ -1398,7 +1234,6 @@ "setting_video_viewer_original_video_title": "Force original video", "settings": "Nustatymai", "settings_require_restart": "Please restart Immich to apply this setting", - "settings_saved": "", "share": "Dalintis", "share_add_photos": "Add photos", "share_assets_selected": "{} selected", @@ -1411,8 +1246,6 @@ "shared_album_section_people_action_leave": "Remove user from album", "shared_album_section_people_action_remove_user": "Remove user from album", "shared_album_section_people_title": "PEOPLE", - "shared_by": "", - "shared_by_you": "", "shared_intent_upload_button_progress_text": "{} / {} Uploaded", "shared_link_app_bar_title": "Shared Links", "shared_link_clipboard_copied_massage": "Copied to clipboard", @@ -1458,20 +1291,15 @@ "show_album_options": "Rodyti albumo parinktis", "show_file_location": "Rodyti rinkmenos vietą", "show_gallery": "Rodyti galeriją", - "show_hidden_people": "", "show_in_timeline": "Rodyti laiko skalėje", "show_in_timeline_setting_description": "Rodyti šio naudotojo nuotraukas ir vaizdo įrašus mano laiko skalėje", - "show_keyboard_shortcuts": "", "show_metadata": "Rodyti metaduomenis", "show_or_hide_info": "Rodyti arba slėpti informaciją", "show_password": "Rodyti slaptažodį", - "show_person_options": "", - "show_progress_bar": "", "show_search_options": "Rodyti paieškos parinktis", "show_slideshow_transition": "Rodyti perėjimą tarp skaidrių", "show_supporter_badge": "Rėmėjo ženklelis", "show_supporter_badge_description": "Rodyti rėmėjo ženklelį", - "shuffle": "", "sidebar": "Šoninė juosta", "sidebar_display_description": "Rodyti rodinio nuorodą šoninėje juostoje", "sign_out": "Atsijungti", @@ -1480,7 +1308,6 @@ "skip_to_content": "Pereiti prie turinio", "slideshow": "Skaidrių peržiūra", "slideshow_settings": "Skaidrių peržiūros nustatymai", - "sort_albums_by": "", "sort_created": "Sukūrimo data", "sort_modified": "Keitimo data", "sort_oldest": "Seniausia nuotrauka", @@ -1492,20 +1319,14 @@ "stack_select_one_photo": "Pasirinkti pagrindinę grupės nuotrauką", "stack_selected_photos": "Grupuoti pasirinktas nuotraukas", "stacked_assets_count": "{count, plural, one {Sugrupuotas # elementas} few {Sugrupuoti # elementai} other {Sugrupuota # elementų}}", - "stacktrace": "", "start": "Pradėti", "start_date": "Pradžios data", - "state": "", "status": "Statusas", - "stop_motion_photo": "", "storage": "Saugykla", - "storage_label": "", "storage_usage": "Naudojama {used} iš {available}", "submit": "Pateikti", - "suggestions": "", "sunrise_on_the_beach": "Saulėtekis paplūdimyje", "support_and_feedback": "Palaikymas ir atsiliepimai", - "swap_merge_direction": "", "sync": "Sinchronizuoti", "sync_albums": "Sync albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", @@ -1519,8 +1340,6 @@ "tags": "Žymos", "template": "Šablonas", "theme": "Tema", - "theme_selection": "", - "theme_selection_description": "", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", @@ -1541,9 +1360,6 @@ "to_change_password": "Pakeisti slaptažodį", "to_favorite": "Įtraukti prie mėgstamiausių", "to_trash": "Išmesti", - "toggle_settings": "", - "toggle_theme": "", - "total_usage": "", "trash": "Šiukšliadėžė", "trash_all": "Perkelti visus į šiukšliadėžę", "trash_count": "Perkelti {count, number} į šiukšliadėžę", @@ -1561,8 +1377,6 @@ "unarchive": "Išarchyvuoti", "unarchived_count": "{count, plural, other {# išarchyvuota}}", "unfavorite": "Pašalinti iš mėgstamiausių", - "unhide_person": "", - "unknown": "", "unknown_year": "Nežinomi metai", "unlink_oauth": "Atsieti OAuth", "unlinked_oauth_account": "Atsieta OAuth paskyra", @@ -1574,7 +1388,6 @@ "unstacked_assets_count": "{count, plural, one {Išgrupuotas # elementas} few {Išgrupuoti # elementai} other {Išgrupuota # elementų}}", "untracked_files": "Nesekami failai", "untracked_files_decription": "Šie failai aplikacijos nesekami. Jie galėjo atsirasti dėl nepavykusio perkėlimo, nutraukto įkėlimo ar palikti per klaidą", - "up_next": "", "updated_at": "Atnaujintas", "updated_password": "Slaptažodis atnaujintas", "upload": "Įkelti", @@ -1597,7 +1410,6 @@ "user_id": "Naudotojo ID", "user_pin_code_settings": "PIN kodas", "user_pin_code_settings_description": "Tvarkykite savo PIN kodą", - "user_usage_detail": "", "user_usage_stats": "Paskyros naudojimo statistika", "user_usage_stats_description": "Žiūrėti paskyros naudojimo statistiką", "username": "Naudotojo vardas", @@ -1625,8 +1437,6 @@ "view_all_users": "Peržiūrėti visus naudotojus", "view_in_timeline": "Žiūrėti laiko skalėje", "view_links": "Žiūrėti nuorodas", - "view_next_asset": "", - "view_previous_asset": "", "view_stack": "Peržiūrėti grupę", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", diff --git a/i18n/lv.json b/i18n/lv.json index 1d46e697b6..6f7c5a4134 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -56,10 +56,6 @@ "face_detection": "Seju noteikšana", "image_format": "Formāts", "image_format_description": "WebP veido mazākus failus nekā JPEG, taču to kodēšana ir lēnāka.", - "image_prefer_embedded_preview": "", - "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", - "image_prefer_wide_gamut_setting_description": "", "image_quality": "Kvalitāte", "image_resolution": "Izšķirtspēja", "image_settings": "Attēla Iestatījumi", @@ -70,98 +66,43 @@ "job_settings_description": "Uzdevumu izpildes vienlaicīguma pārvaldība", "job_status": "Uzdevumu statuss", "library_deleted": "Bibliotēka dzēsta", - "library_scanning": "", - "library_scanning_description": "", - "library_scanning_enable_description": "", - "library_settings": "", "library_settings_description": "Ārējo bibliotēku iestatījumu pārvaldība", - "library_tasks_description": "", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", "machine_learning_clip_model": "CLIP modelis", "machine_learning_duplicate_detection": "Dublikātu noteikšana", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", "machine_learning_facial_recognition": "Seju atpazīšana", - "machine_learning_facial_recognition_description": "", "machine_learning_facial_recognition_model": "Seju atpazīšanas modelis", - "machine_learning_facial_recognition_model_description": "", - "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", - "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", - "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", "machine_learning_settings": "Mašīnmācīšanās iestatījumi", "machine_learning_settings_description": "Mašīnmācīšanās funkciju un iestatījumu pārvaldība", "machine_learning_smart_search": "Viedā meklēšana", - "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", "machine_learning_url_description": "Mašīnmācīšanās servera URL", "manage_concurrency": "Vienlaicīgas darbības pārvaldība", "manage_log_settings": "Žurnāla iestatījumu pārvaldība", - "map_dark_style": "", - "map_enable_description": "", "map_gps_settings": "Kartes un GPS iestatījumi", "map_gps_settings_description": "Karšu un GPS (apgrieztās ģeokodēšanas) iestatījumu pārvaldība", - "map_light_style": "", "map_manage_reverse_geocoding_settings": "<link>Reversās ģeokodēšanas</link> iestatījumu pārvaldība", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", "map_settings": "Karte", "map_settings_description": "Kartes iestatījumu pārvaldība", - "map_style_description": "", "metadata_extraction_job": "Metadatu iegūšana", - "metadata_extraction_job_description": "", "metadata_settings": "Metadatu iestatījumi", "metadata_settings_description": "Metadatu iestatījumu pārvaldība", "migration_job": "Migrācija", - "migration_job_description": "", "no_paths_added": "Nav pievienots neviens ceļš", "no_pattern_added": "Nav pievienots neviens izslēgšanas šablons", "note_cannot_be_changed_later": "PIEZĪME: Vēlāk to vairs nevar mainīt!", "notification_email_from_address": "No adreses", "notification_email_from_address_description": "Sūtītāja e-pasta adrese, piemēram: “Immich foto serveris <noreply@example.com>”", - "notification_email_host_description": "", "notification_email_ignore_certificate_errors": "Ignorēt sertifikātu kļūdas", "notification_email_ignore_certificate_errors_description": "Ignorēt TLS sertifikāta apstiprināšanas kļūdas (nav ieteicams)", - "notification_email_password_description": "", "notification_email_port_description": "e-pasta servera ports (piemēram, 25, 465 vai 587)", "notification_email_sent_test_email_button": "Nosūtīt testa e-pastu un saglabāt", - "notification_email_setting_description": "", "notification_email_test_email": "Nosūtīt testa e-pastu", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", "notification_settings": "Paziņojumu iestatījumi", "notification_settings_description": "Paziņojumu iestatījumu, tostarp e-pasta, pārvaldība", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", "oauth_button_text": "Pogas teksts", "oauth_enable_description": "Pieslēgties ar OAuth", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", "oauth_settings": "OAuth", "oauth_settings_description": "OAuth pieteikšanās iestatījumu pārvaldība", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", "oauth_storage_quota_default": "Noklusējuma krātuves kvota (GiB)", - "oauth_storage_quota_default_description": "", "password_enable_description": "Pieteikšanās ar e-pasta adresi un paroli", "password_settings": "Pieteikšanās ar paroli", "password_settings_description": "Pieteikšanās ar paroli iestatījumu pārvaldība", @@ -172,105 +113,42 @@ "require_password_change_on_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", "scanning_library": "Skenē bibliotēku", "search_jobs": "Meklēt uzdevumus…", - "server_external_domain_settings": "", - "server_external_domain_settings_description": "", "server_settings": "Servera iestatījumi", "server_settings_description": "Servera iestatījumu pārvaldība", "server_welcome_message": "Sveiciena ziņa", "server_welcome_message_description": "Ziņojums, kas tiek parādīts pieslēgšanās lapā.", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", "storage_template_date_time_sample": "Laika paraugs {date}", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", "storage_template_migration": "Krātuves veidņu migrācija", "storage_template_migration_job": "Krātuves veidņu migrācijas uzdevums", "storage_template_path_length": "Aptuvenais ceļa garuma ierobežojums: <b>{length, number}</b>/{limit, number}", "storage_template_settings": "Krātuves veidne", - "storage_template_settings_description": "", "system_settings": "Sistēmas iestatījumi", "template_email_preview": "Priekšskatījums", "template_email_settings_description": "Pielāgotu e-pasta paziņojumu veidņu pārvaldība", "template_settings_description": "Pielāgotu paziņojumu veidņu pārvaldība", "theme_custom_css_settings": "Pielāgots CSS", "theme_custom_css_settings_description": "Cascading Style Sheets ļauj pielāgot Immich izskatu.", - "theme_settings": "", "theme_settings_description": "Immich tīmekļa saskarnes pielāgojumu pārvaldība", "thumbnail_generation_job": "Sīktēlu ģenerēšana", - "thumbnail_generation_job_description": "", "transcoding_acceleration_api": "Paātrināšanas API", - "transcoding_acceleration_api_description": "", "transcoding_acceleration_nvenc": "NVENC (nepieciešams NVIDIA GPU)", "transcoding_acceleration_qsv": "Quick Sync (nepieciešams 7. paaudzes vai jaunāks Intel procesors)", "transcoding_acceleration_rkmpp": "RKMPP (tikai Rockchip SOC)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", "transcoding_advanced_options_description": "Lielākajai daļai lietotāju nevajadzētu mainīt šīs opcijas", "transcoding_audio_codec": "Audio kodeks", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", "transcoding_codecs_learn_more": "Lai uzzinātu vairāk par šeit lietoto terminoloģiju, skatiet FFmpeg dokumentāciju par <h264-link>H.264 kodeku</h264-link>, <hevc-link>HEVC kodeku</hevc-link> un <vp9-link>VP9 kodeku</vp9-link>.", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", "transcoding_threads": "Pavedieni", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", "transcoding_video_codec": "Video kodeks", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", "trash_number_of_days": "Dienu skaits", - "trash_number_of_days_description": "", - "trash_settings": "", "trash_settings_description": "Atkritnes iestatījumu pārvaldība", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", "user_management": "Lietotāju pārvaldība", "user_password_has_been_reset": "Lietotāja parole ir atiestatīta:", "user_restore_description": "<b>{user}</b> konts tiks atjaunots.", - "user_settings": "", "user_settings_description": "Lietotāju iestatījumu pārvaldība", "version_check_enabled_description": "Ieslēgt versijas pārbaudi", "version_check_implications": "Versiju pārbaudes funkcija ir atkarīga no periodiskas saziņas ar github.com", - "version_check_settings": "Versijas pārbaude", - "version_check_settings_description": "", - "video_conversion_job_description": "" + "version_check_settings": "Versijas pārbaude" }, "admin_email": "Administratora e-pasts", "admin_password": "Administratora parole", @@ -290,21 +168,18 @@ "age_year_months": "Vecums 1 gads, {months, plural, zero {# mēnešu} one {# mēnesis} other {# mēneši}}", "age_years": "{years, plural, zero {# gadu} one {# gads} other {# gadi}}", "album_added": "Albums pievienots", - "album_added_notification_setting_description": "", "album_cover_updated": "Albuma attēls atjaunināts", "album_info_card_backup_album_excluded": "NEIEKĻAUTS", "album_info_card_backup_album_included": "IEKĻAUTS", "album_info_updated": "Albuma informācija atjaunināta", "album_leave": "Pamest albumu?", "album_name": "Albuma nosaukums", - "album_options": "", "album_remove_user": "Noņemt lietotāju?", "album_thumbnail_card_item": "1 vienums", "album_thumbnail_card_items": "{count} vienumi", "album_thumbnail_card_shared": " · Kopīgots", "album_thumbnail_shared_by": "Kopīgoja {user}", "album_updated": "Albums atjaunināts", - "album_updated_setting_description": "", "album_user_left": "Pameta {album}", "album_user_removed": "Noņēma {user}", "album_viewer_appbar_delete_confirm": "Vai tiešām vēlaties dzēst šo albumu no sava konta?", @@ -331,10 +206,7 @@ "app_bar_signout_dialog_content": "Vai tiešām vēlaties izrakstīties?", "app_bar_signout_dialog_ok": "Jā", "app_bar_signout_dialog_title": "Izrakstīties", - "app_settings": "", - "appears_in": "", "archive": "Arhīvs", - "archive_or_unarchive_photo": "", "archive_page_no_archived_assets": "Nav atrasts neviens arhivēts aktīvs", "archive_page_title": "Arhīvs ({count})", "archive_size": "Arhīva izmērs", @@ -351,7 +223,6 @@ "asset_list_layout_sub_title": "Izvietojums", "asset_list_settings_subtitle": "Fotorežģa izkārtojuma iestatījumi", "asset_list_settings_title": "Fotorežģis", - "asset_offline": "", "asset_restored_successfully": "Asset restored successfully", "asset_uploading": "Augšupielādē…", "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", @@ -431,10 +302,8 @@ "backup_manual_title": "Augšupielādes statuss", "backup_options_page_title": "Dublēšanas iestatījumi", "backup_setting_subtitle": "Manage background and foreground upload settings", - "backward": "", "birthdate_saved": "Dzimšanas datums veiksmīgi saglabāts", "birthdate_set_description": "Dzimšanas datums tiek izmantots, lai aprēķinātu šīs personas vecumu fotogrāfijas uzņemšanas brīdī.", - "blurred_background": "", "bugs_and_feature_requests": "Kļūdas un funkciju pieprasījumi", "build": "Būvējums", "build_image": "Būvējuma attēls", @@ -456,14 +325,9 @@ "cache_settings_tile_subtitle": "Kontrolēt lokālās krātuves uzvedību", "cache_settings_tile_title": "Lokālā Krātuve", "cache_settings_title": "Kešdarbes iestatījumi", - "camera": "", - "camera_brand": "", - "camera_model": "", "cancel": "Atcelt", - "cancel_search": "", "canceled": "Canceled", "cannot_merge_people": "Nevar apvienot cilvēkus", - "cannot_update_the_description": "", "change_date": "Mainīt datumu", "change_display_order": "Change display order", "change_expiration_time": "Izmainīt derīguma termiņu", @@ -477,17 +341,13 @@ "change_password_form_password_mismatch": "Paroles nesakrīt", "change_password_form_reenter_new_password": "Atkārtoti ievadīt jaunu paroli", "change_pin_code": "Nomainīt PIN kodu", - "change_your_password": "", - "changed_visibility_successfully": "", "check_corrupt_asset_backup": "Check for corrupt asset backups", "check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", - "check_logs": "", "choose_matching_people_to_merge": "Izvēlies atbilstošus cilvēkus apvienošanai", "city": "Pilsēta", "clear": "Notīrīt", "clear_all": "Notīrīt visu", - "clear_message": "", "clear_value": "Notīrīt vērtību", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", @@ -504,16 +364,12 @@ "color": "Krāsa", "color_theme": "Krāsu tēma", "comment_deleted": "Komentārs dzēsts", - "comment_options": "", - "comments_are_disabled": "", "common_create_new_album": "Izveidot jaunu albumu", "common_server_error": "Lūdzu, pārbaudiet tīkla savienojumu, pārliecinieties, vai serveris ir sasniedzams un aplikācijas/servera versijas ir saderīgas.", "completed": "Completed", "confirm": "Apstiprināt", - "confirm_admin_password": "", "confirm_new_pin_code": "Apstiprināt jauno PIN kodu", "confirm_password": "Apstiprināt paroli", - "contain": "", "context": "Konteksts", "continue": "Turpināt", "control_bottom_app_bar_album_info_shared": "{count} vienumi · Koplietoti", @@ -525,17 +381,8 @@ "control_bottom_app_bar_share_link": "Share Link", "control_bottom_app_bar_share_to": "Kopīgot Uz", "control_bottom_app_bar_trash_from_immich": "Pārvietot uz Atkritni", - "copied_image_to_clipboard": "", "copy_error": "Kopēšanas kļūda", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", "country": "Valsts", - "cover": "", - "covers": "", "create": "Izveidot", "create_album": "Izveidot albumu", "create_album_page_untitled": "Bez nosaukuma", @@ -548,17 +395,12 @@ "create_shared_album_page_share_add_assets": "PIEVIENOT AKTĪVUS", "create_shared_album_page_share_select_photos": "Fotoattēlu Izvēle", "create_user": "Izveidot lietotāju", - "created": "", "crop": "Crop", "curated_object_page_title": "Lietas", - "current_device": "", "current_pin_code": "Esošais PIN kods", "current_server_address": "Current server address", - "custom_locale": "", - "custom_locale_description": "", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, gggg", - "dark": "", "date_after": "Datums pēc", "date_and_time": "Datums un Laiks", "date_before": "Datums pirms", @@ -567,8 +409,6 @@ "date_range": "Datumu diapazons", "day": "Diena", "deduplication_criteria_1": "Attēla izmērs baitos", - "default_locale": "", - "default_locale_description": "", "delete": "Dzēst", "delete_album": "Dzēst albumu", "delete_dialog_alert": "Šie vienumi tiks neatgriezeniski dzēsti no Immich un jūsu ierīces", @@ -593,15 +433,8 @@ "description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā", "details": "INFORMĀCIJA", "direction": "Virziens", - "disallow_edits": "", "discord": "Discord", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", "display_order": "Attēlošanas secība", - "display_original_photos": "", - "display_original_photos_setting_description": "", "documentation": "Dokumentācija", "done": "Gatavs", "download": "Lejupielādēt", @@ -624,13 +457,10 @@ "downloading_asset_filename": "Lejupielādē failu {filename}", "downloading_media": "Downloading media", "duplicates": "Dublikāti", - "duration": "", "edit": "Labot", "edit_album": "Labot albumu", - "edit_avatar": "", "edit_date": "Labot datumu", "edit_date_and_time": "Labot datumu un laiku", - "edit_exclusion_pattern": "", "edit_faces": "Labot sejas", "edit_import_path": "Labot importa ceļu", "edit_import_paths": "Labot importa ceļus", @@ -650,66 +480,18 @@ "email_notifications": "E-pasta paziņojumi", "empty_folder": "This folder is empty", "empty_trash": "Iztukšot atkritni", - "enable": "", - "enabled": "", - "end_date": "", "enqueued": "Enqueued", "enter_wifi_name": "Enter WiFi name", - "error": "", "error_change_sort_album": "Failed to change album sort order", - "error_loading_image": "", "error_saving_image": "Kļūda: {error}", "errors": { "cant_get_faces": "Nevar iegūt sejas", "cant_search_people": "Neizdevās veikt peronu meklēšanu", "failed_to_create_album": "Neizdevās izveidot albumu", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_create_admin_account": "", - "unable_to_create_library": "", "unable_to_create_user": "Neizdevās izveidot lietotāju", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", "unable_to_delete_user": "Neizdevās dzēst lietotāju", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", "unable_to_hide_person": "Neizdevās paslēpt personu", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_user": "" + "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu" }, "exif_bottom_sheet_description": "Pievienot Aprakstu...", "exif_bottom_sheet_details": "INFORMĀCIJA", @@ -721,7 +503,6 @@ "exif_bottom_sheet_person_age_year_months": "Vecums 1 gads, {months} mēneši", "exif_bottom_sheet_person_age_years": "Vecums {years}", "exit_slideshow": "Iziet no slīdrādes", - "expand_all": "", "experimental_settings_new_asset_list_subtitle": "Izstrādes posmā", "experimental_settings_new_asset_list_title": "Iespējot eksperimentālo fotorežģi", "experimental_settings_subtitle": "Izmanto uzņemoties risku!", @@ -729,38 +510,21 @@ "expire_after": "Derīguma termiņš beidzas pēc", "expired": "Derīguma termiņš beidzās", "explore": "Izpētīt", - "extension": "", - "external_libraries": "", "external_network": "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", "failed": "Failed", "failed_to_load_assets": "Failed to load assets", "failed_to_load_folder": "Failed to load folder", "favorite": "Izlase", - "favorite_or_unfavorite_photo": "", "favorites": "Izlase", "favorites_page_no_favorites": "Nav atrasti iecienītākie aktīvi", - "feature_photo_updated": "", "features_setting_description": "Lietotnes funkciju pārvaldība", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", "filter": "Filter", - "filter_people": "", - "fix_incorrect_match": "", "folder": "Folder", "folder_not_found": "Folder not found", "folders": "Mapes", - "forward": "", - "general": "", - "get_help": "", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "getting_started": "", - "go_back": "", - "go_to_search": "", "grant_permission": "Grant permission", - "group_albums_by": "", "haptic_feedback_switch": "Iestatīt haptisku reakciju", "haptic_feedback_title": "Haptiska Reakcija", "has_quota": "Ir kvota", @@ -770,9 +534,7 @@ "header_settings_header_value_input": "Header value", "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", "headers_settings_tile_title": "Custom proxy headers", - "hide_gallery": "", "hide_named_person": "Paslēpt personu {name}", - "hide_password": "", "hide_person": "Paslēpt personu", "home_page_add_to_album_conflicts": "Pievienoja {added} aktīvus albumam {album}. {failed} aktīvi jau ir albumā.", "home_page_add_to_album_err_local": "Albumiem vēl nevar pievienot lokālos aktīvus, notiek izlaišana", @@ -788,8 +550,6 @@ "home_page_first_time_notice": "Ja šī ir pirmā reize, kad izmantojat aplikāciju, lūdzu, izvēlieties dublējuma albumu(s), lai laika skala varētu aizpildīt fotoattēlus un videoklipus albumā(os).", "home_page_share_err_local": "Caur saiti nevarēja kopīgot lokālos aktīvus, notiek izlaišana", "home_page_upload_err_limit": "Vienlaikus var augšupielādēt ne vairāk kā 30 aktīvus, notiek izlaišana", - "host": "", - "hour": "", "ignore_icloud_photos": "Ignore iCloud photos", "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image": "Attēls", @@ -804,12 +564,9 @@ "in_archive": "Arhīvā", "include_archived": "Iekļaut arhivētos", "include_shared_albums": "Iekļaut koplietotos albumus", - "include_shared_partner_assets": "", - "individual_share": "", "info": "Informācija", "interval": { "day_at_onepm": "Katru dienu 13.00", - "hours": "", "night_at_midnight": "Katru dienu pusnaktī", "night_at_twoam": "Katru dienu 2.00 naktī" }, @@ -830,20 +587,14 @@ "let_others_respond": "Ļaut citiem atbildēt", "level": "Līmenis", "library": "Bibliotēka", - "library_options": "", "library_page_device_albums": "Albumi ierīcē", "library_page_new_album": "Jauns albums", "library_page_sort_asset_count": "Daudzums ar aktīviem", "library_page_sort_created": "Jaunākais izveidotais", "library_page_sort_last_modified": "Pēdējo reizi modificēts", "library_page_sort_title": "Albuma virsraksts", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", "list": "Saraksts", "loading": "Ielādē", - "loading_search_results_failed": "", "local_network": "Local network", "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", "location_permission": "Location permission", @@ -854,7 +605,6 @@ "location_picker_longitude_error": "Ievadiet korektu ģeogrāfisko garumu", "location_picker_longitude_hint": "Ievadiet savu ģeogrāfisko garumu šeit", "log_out": "Izrakstīties", - "log_out_all_devices": "", "login_disabled": "Pieslēgšanās ir atslēgta", "login_form_api_exception": "API izņēmums. Lūdzu, pārbaudiet servera URL un mēģiniet vēlreiz.", "login_form_back_button_text": "Atpakaļ", @@ -874,12 +624,10 @@ "login_form_save_login": "Palikt pieteiktam", "login_form_server_empty": "Ieraksties servera URL.", "login_form_server_error": "Nevarēja izveidot savienojumu ar serveri.", - "login_has_been_disabled": "", "login_password_changed_error": "Atjaunojot paroli radās kļūda", "login_password_changed_success": "Parole veiksmīgi atjaunota", "longitude": "Ģeogrāfiskais garums", "look": "Izskats", - "loop_videos": "", "loop_videos_description": "Iespējot, lai automātiski videoklips tiktu cikliski palaists detaļu skatītājā.", "make": "Firma", "manage_shared_links": "Kopīgoto saišu pārvaldība", @@ -919,7 +667,6 @@ "memories": "Atmiņas", "memories_all_caught_up": "Šobrīd, tas arī viss", "memories_check_back_tomorrow": "Priekš vairāk atmiņām atgriezieties rītdien.", - "memories_setting_description": "", "memories_start_over": "Sākt no jauna", "memories_swipe_to_close": "Pavelciet uz augšu, lai aizvērtu", "memories_year_ago": "A year ago", @@ -955,25 +702,19 @@ "new_pin_code": "Jaunais PIN kods", "new_user_created": "Izveidots jauns lietotājs", "new_version_available": "PIEEJAMA JAUNA VERSIJA", - "newest_first": "", "next": "Nākošais", "next_memory": "Nākamā atmiņa", "no": "Nē", "no_albums_message": "Izveido albumu, lai organizētu savas fotogrāfijas un video", - "no_archived_assets_message": "", "no_assets_message": "NOKLIKŠĶINIET, LAI AUGŠUPIELĀDĒTU SAVU PIRMO FOTOATTĒLU", "no_assets_to_show": "Nav uzrādāmo aktīvu", "no_duplicates_found": "Dublikāti netika atrasti.", "no_exif_info_available": "Nav pieejama exif informācija", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", "no_name": "Nav nosaukuma", "no_notifications": "Nav paziņojumu", "no_places": "Nav atrašanās vietu", "no_results": "Nav rezultātu", "no_results_description": "Izmēģiniet sinonīmu vai vispārīgāku atslēgvārdu", - "no_shared_albums_message": "", "not_in_any_album": "Nav nevienā albumā", "not_selected": "Not selected", "notes": "Piezīmes", @@ -988,7 +729,6 @@ "official_immich_resources": "Oficiālie Immich resursi", "offline": "Bezsaistē", "ok": "Labi", - "oldest_first": "", "on_this_device": "On this device", "online": "Tiešsaistē", "only_favorites": "Tikai izlase", @@ -997,7 +737,6 @@ "open_the_search_filters": "Atvērt meklēšanas filtrus", "options": "Iestatījumi", "or": "vai", - "organize_your_library": "", "original": "oriģināls", "other": "Citi", "other_devices": "Citas ierīces", @@ -1013,29 +752,11 @@ "partner_page_select_partner": "Izvēlēties partneri", "partner_page_shared_to_title": "Kopīgots uz", "partner_page_stop_sharing_content": "{partner} vairs nevarēs piekļūt jūsu fotoattēliem.", - "partner_sharing": "", "partners": "Partneri", "password": "Parole", "password_does_not_match": "Parole nesakrīt", - "password_required": "", - "password_reset_success": "", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, "path": "Ceļš", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", "people": "Cilvēki", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", "permission_onboarding_back": "Atpakaļ", "permission_onboarding_continue_anyway": "Tomēr turpināt", "permission_onboarding_get_started": "Darba sākšana", @@ -1047,22 +768,11 @@ "person": "Persona", "photos": "Fotoattēli", "photos_from_previous_years": "Fotogrāfijas no iepriekšējiem gadiem", - "pick_a_location": "", - "place": "", "places": "Vietas", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", "port": "Ports", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Iestatījumi", - "preset": "", "preview": "Priekšskatījums", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", "privacy": "Privātums", "profile": "Profils", "profile_drawer_app_logs": "Žurnāli", @@ -1072,8 +782,6 @@ "profile_drawer_github": "GitHub", "profile_drawer_server_out_of_date_major": "Serveris ir novecojis. Lūdzu atjaunojiet to uz jaunāko lielo versiju", "profile_drawer_server_out_of_date_minor": "Serveris ir novecojis. Lūdzu atjaunojiet to uz jaunāko mazo versiju", - "profile_picture_set": "", - "public_share": "", "purchase_button_never_show_again": "Nekad vairs nerādīt", "purchase_button_reminder": "Atgādināt man pēc 30 dienām", "purchase_button_remove_key": "Noņemt atslēgu", @@ -1091,33 +799,20 @@ "purchase_server_title": "Serveris", "purchase_settings_server_activated": "Servera produkta atslēgu pārvalda administrators", "rating_clear": "Noņemt vērtējumu", - "reaction_options": "", "read_changelog": "Lasīt izmaiņu sarakstu", - "recent": "", - "recent_searches": "", "recently_added": "Recently added", "recently_added_page_title": "Nesen Pievienotais", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", "remove": "Noņemt", - "remove_deleted_assets": "", "remove_from_album": "Noņemt no albuma", "remove_from_favorites": "Noņemt no izlases", - "remove_from_shared_link": "", "remove_user": "Noņemt lietotāju", "removed_api_key": "Noņēma API atslēgu: {name}", "removed_from_archive": "Noņēma no arhīva", "removed_from_favorites": "Noņēma no izlases", "rename": "Pārsaukt", "repair": "Remonts", - "repair_no_results_message": "", "replace_with_upload": "Aizstāt ar augšupielādi", - "require_password": "", "require_user_to_change_password_on_first_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", "resolve_duplicates": "Atrisināt dublēšanās gadījumus", "resolved_all_duplicates": "Visi dublikāti ir atrisināti", "restore": "Atjaunot", @@ -1136,16 +831,9 @@ "saved_settings": "Iestatījumi saglabāti", "say_something": "Teikt kaut ko", "scaffold_body_error_occurred": "Radās kļūda", - "scan_all_libraries": "", - "scan_settings": "", "search": "Meklēt", "search_albums": "Meklēt albumus", - "search_by_context": "", "search_by_filename_example": "piemēram, IMG_1234.JPG vai PNG", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", "search_filter_apply": "Lietot filtru", "search_filter_camera_title": "Select camera type", "search_filter_date": "Date", @@ -1159,7 +847,6 @@ "search_filter_media_type": "Media Type", "search_filter_media_type_title": "Select media type", "search_filter_people_title": "Select people", - "search_for_existing_person": "", "search_no_more_result": "No more results", "search_no_people": "Nav cilvēku", "search_no_people_named": "Nav cilvēku ar vārdu \"{name}\"", @@ -1176,40 +863,22 @@ "search_page_your_activity": "Jūsu aktivitāte", "search_page_your_map": "Jūsu Karte", "search_people": "Meklēt cilvēkus", - "search_places": "", "search_result_page_new_search_hint": "Jauns Meklējums", - "search_state": "", "search_suggestion_list_smart_search_hint_1": "Viedā meklēšana ir iespējota pēc noklusējuma, lai meklētu metadatus, izmantojiet sintaksi", "search_suggestion_list_smart_search_hint_2": "m:jūsu-meklēšanas-frāze", - "search_timezone": "", - "search_type": "", "search_your_photos": "Meklēt Jūsu fotoattēlus", - "searching_locales": "", "second": "Sekunde", "select_album_cover": "Izvēlieties albuma vāciņu", - "select_all": "", "select_all_duplicates": "Atlasīt visus dublikātus", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", "select_photos": "Fotoattēlu Izvēle", "select_user_for_sharing_page_err_album": "Neizdevās izveidot albumu", - "selected": "", - "send_message": "", "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Aplikācijas Versija", "server_info_box_server_url": "Servera URL", "server_online": "Serveris tiešsaistē", "server_stats": "Servera statistika", "server_version": "Servera versija", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", "set_date_of_birth": "Iestatīt dzimšanas datumu", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", "setting_image_viewer_help": "Detaļu skatītājs vispirms ielādē mazo sīktēlu, pēc tam ielādē vidēja lieluma priekšskatījumu (ja iespējots), visbeidzot ielādē oriģinālu (ja iespējots).", "setting_image_viewer_original_subtitle": "Iespējot sākotnējā pilnas izšķirtspējas attēla (liels!) ielādi. Atspējot, lai samazinātu datu lietojumu (gan tīklā, gan ierīces kešatmiņā).", "setting_image_viewer_original_title": "Ielādēt oriģinālo attēlu", @@ -1235,7 +904,6 @@ "setting_video_viewer_original_video_title": "Force original video", "settings": "Iestatījumi", "settings_require_restart": "Lūdzu, restartējiet Immich, lai lietotu šo iestatījumu", - "settings_saved": "", "setup_pin_code": "Uzstādīt PIN kodu", "share": "Kopīgot", "share_add_photos": "Pievienot fotoattēlus", @@ -1249,8 +917,6 @@ "shared_album_section_people_action_leave": "Noņemt lietotāju no albuma", "shared_album_section_people_action_remove_user": "Noņemt lietotāju no albuma", "shared_album_section_people_title": "CILVĒKI", - "shared_by": "", - "shared_by_you": "", "shared_intent_upload_button_progress_text": "Augšupielādēti {current} / {total}", "shared_link_app_bar_title": "Kopīgotas Saites", "shared_link_clipboard_copied_massage": "Ievietots starpliktuvē", @@ -1286,7 +952,6 @@ "sharing_page_album": "Kopīgotie albumi", "sharing_page_description": "Izveidojiet koplietojamus albumus, lai kopīgotu fotoattēlus un videoklipus ar Jūsu tīkla lietotājiem.", "sharing_page_empty_list": "TUKŠS SARAKSTS", - "sharing_sidebar_description": "", "sharing_silver_appbar_create_shared_album": "Izveidot kopīgotu albumu", "sharing_silver_appbar_share_partner": "Dalīties ar partneri", "show_album_options": "Rādīt albuma iespējas", @@ -1296,21 +961,10 @@ "show_file_location": "Rādīt faila atrašanās vietu", "show_gallery": "Rādīt galeriju", "show_hidden_people": "Rādīt paslēptos cilvēkus", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", "show_metadata": "Rādīt metadatus", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", "show_supporter_badge": "Atbalstītāja nozīmīte", "show_supporter_badge_description": "Rādīt atbalstītāja nozīmīti", - "shuffle": "", - "sign_up": "", "size": "Izmērs", - "skip_to_content": "", "slideshow": "Slīdrāde", "slideshow_settings": "Slīdrādes iestatījumi", "sort_albums_by": "Kārtot albumus pēc...", @@ -1322,30 +976,21 @@ "sort_title": "Nosaukums", "source": "Pirmkods", "stack": "Apvienot kaudzē", - "stack_selected_photos": "", - "stacktrace": "", - "start_date": "", "state": "Štats", "status": "Statuss", - "stop_motion_photo": "", "stop_photo_sharing": "Beigt kopīgot jūsu fotogrāfijas?", "storage": "Vieta krātuvē", - "storage_label": "", "storage_usage": "{used} no {available} izmantoti", "submit": "Iesniegt", "suggestions": "Ieteikumi", "sunrise_on_the_beach": "Saullēkts pludmalē", "support": "Atbalsts", "support_and_feedback": "Atbalsts un atsauksmes", - "swap_merge_direction": "", "sync": "Sinhronizēt", "sync_albums": "Sync albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", - "template": "", "theme": "Dizains", - "theme_selection": "", - "theme_selection_description": "", "theme_setting_asset_list_storage_indicator_title": "Rādīt krātuves indikatoru uz aktīvu elementiem", "theme_setting_asset_list_tiles_per_row_title": "Failu skaits rindā ({count})", "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", @@ -1360,17 +1005,14 @@ "theme_setting_three_stage_loading_subtitle": "Trīspakāpju ielāde var palielināt ielādēšanas veiktspēju, bet izraisa ievērojami lielāku tīkla noslodzi", "theme_setting_three_stage_loading_title": "Iespējot trīspakāpju ielādi", "they_will_be_merged_together": "Tās tiks apvienotas", - "time_based_memories": "", "timezone": "Laika zona", "to_archive": "Arhivēt", "to_change_password": "Mainīt paroli", "toggle_settings": "Pārslēgt iestatījumus", - "toggle_theme": "", "total_usage": "Kopējais lietojums", "trash": "Atkritne", "trash_all": "Dzēst Visu", "trash_emptied": "Emptied trash", - "trash_no_results_message": "", "trash_page_delete_all": "Dzēst Visu", "trash_page_empty_trash_dialog_content": "Vai vēlaties iztukšot savus izmestos aktīvus? Tie tiks neatgriezeniski izņemti no Immich", "trash_page_info": "Atkritnes vienumi tiks neatgriezeniski dzēsti pēc {days} dienām", @@ -1378,26 +1020,19 @@ "trash_page_restore_all": "Atjaunot Visu", "trash_page_select_assets_btn": "Atlasīt aktīvus", "trash_page_title": "Atkritne ({count})", - "type": "", "unable_to_change_pin_code": "Neizdevās nomainīt PIN kodu", "unable_to_setup_pin_code": "Neizdevās uzstādīt PIN kodu", "unarchive": "Atarhivēt", "unfavorite": "Noņemt no izlases", "unhide_person": "Atcelt personas slēpšanu", - "unknown": "", "unknown_country": "Nezināma Valsts", "unknown_year": "Nezināms gads", "unlimited": "Neierobežots", - "unlink_oauth": "", - "unlinked_oauth_account": "", "unnamed_album": "Albums bez nosaukuma", "unsaved_change": "Nesaglabāta izmaiņa", - "unselect_all": "", "unstack": "At-Stekot", - "up_next": "", "updated_password": "Parole ir atjaunināta", "upload": "Augšupielādēt", - "upload_concurrency": "", "upload_dialog_info": "Vai vēlaties veikt izvēlētā(-o) aktīva(-u) dublējumu uz servera?", "upload_dialog_title": "Augšupielādēt Aktīvu", "upload_status_duplicates": "Dublikāti", @@ -1405,7 +1040,6 @@ "upload_status_uploaded": "Augšupielādēts", "upload_to_immich": "Augšupielādēt Immich ({count})", "uploading": "Uploading", - "url": "", "usage": "Lietojums", "use_current_connection": "use current connection", "user": "Lietotājs", @@ -1416,9 +1050,7 @@ "username": "Lietotājvārds", "users": "Lietotāji", "utilities": "Rīki", - "validate": "", "validate_endpoint_error": "Please enter a valid URL", - "variables": "", "version": "Versija", "version_announcement_message": "Sveiki! Ir pieejama jauna Immich versija. Lūdzu, veltiet laiku, lai izlasītu <link>laidiena piezīmes</link> un pārliecinātos, ka jūsu iestatījumi ir atjaunināti, lai novērstu jebkādu nepareizu konfigurāciju, jo īpaši, ja izmantojat WatchTower vai citu mehānismu, kas automātiski atjaunina jūsu Immich instanci.", "version_announcement_overlay_release_notes": "informācija par laidienu", @@ -1429,20 +1061,15 @@ "version_history": "Versiju vēsture", "version_history_item": "{version} uzstādīta {date}", "video": "Videoklips", - "video_hover_setting_description": "", "videos": "Videoklipi", "view_album": "Skatīt Albumu", "view_all": "Apskatīt visu", "view_all_users": "Skatīt visus lietotājus", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", "viewer_remove_from_stack": "Noņemt no Steka", "viewer_stack_use_as_main_asset": "Izmantot kā Galveno Aktīvu", "viewer_unstack": "At-Stekot", "waiting": "Gaida", "week": "Nedēļa", - "welcome_to_immich": "", "wifi_name": "WiFi Name", "year": "Gads", "years_ago": "Pirms {years, plural, one {# gada} other {# gadiem}}", diff --git a/i18n/mn.json b/i18n/mn.json index f539c64813..cb40dc4ea5 100644 --- a/i18n/mn.json +++ b/i18n/mn.json @@ -15,14 +15,10 @@ "add_a_name": "Нэр өгөх", "add_a_title": "Гарчиг оруулах", "add_endpoint": "Add endpoint", - "add_exclusion_pattern": "", - "add_import_path": "", "add_location": "Байршил оруулах", "add_more_users": "Өөр хэрэглэгчид нэмэх", "add_partner": "Хамтрагч нэмэх", - "add_path": "", "add_photos": "Зураг нэмэх", - "add_to": "", "add_to_album": "Цомогт оруулах", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", @@ -35,36 +31,10 @@ "authentication_settings_description": "Нууц үгийн удирдлага, OAuth болон бусад танин нэвтрэлтийн тохиргоо", "authentication_settings_disable_all": "Бүх нэвтрэх аргуудыг идэвхигүй болгохдоо итгэлтэй байна уу? Нэвтрэх үйлдэл бүрэн идэвхигүй болно.", "check_all": "Бүгдийг сонгох", - "disable_login": "", - "duplicate_detection_job_description": "", "face_detection": "Нүүр илрүүлэх", - "image_format_description": "", - "image_prefer_embedded_preview": "", - "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", - "image_prefer_wide_gamut_setting_description": "", "image_quality": "Чанар", - "image_settings": "", - "image_settings_description": "", "job_settings": "Ажлын тохиргоо", - "job_settings_description": "", "job_status": "Ажлын төлөв", - "library_scanning": "", - "library_scanning_description": "", - "library_scanning_enable_description": "", - "library_settings": "", - "library_settings_description": "", - "library_tasks_description": "", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", "machine_learning_enabled": "Машин сургалт идэвхжүүлэх", "machine_learning_enabled_description": "Идэвхгүй болгосон үед доорх тохиргооноос хамаарахгүйгээр бүх машин сургалтын боломж идэвхгүй болно.", "machine_learning_facial_recognition": "Нүүр танилт", @@ -72,162 +42,18 @@ "machine_learning_facial_recognition_model": "Нүүр танилтын загвар", "machine_learning_facial_recognition_model_description": "Загварууд хэмжээ нь буурах эрэмбээр жагссан. Том загварууд удаан, илүү их санах ой хэрэглэх боловч харьцангуй чанартай үр дүн үзүүлнэ. Загвар өөрчилсөн тохиолдолд нүүр илрүүлэлтийн ажлыг дахин эхлүүлэх шаардлагатайг санаарай.", "machine_learning_facial_recognition_setting": "Нүүр танилт идэвхжүүлэх", - "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", - "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", - "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", - "machine_learning_settings": "", - "machine_learning_settings_description": "", - "machine_learning_smart_search": "", - "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", - "manage_log_settings": "", - "map_dark_style": "", - "map_enable_description": "", - "map_light_style": "", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", "map_settings": "Газрын зураг", - "map_settings_description": "", - "map_style_description": "", - "metadata_extraction_job_description": "", - "migration_job_description": "", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", - "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", - "notification_settings": "", - "notification_settings_description": "", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_enable_description": "", - "oauth_mobile_redirect_uri": "", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_settings": "", - "oauth_settings_description": "", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", - "oauth_storage_quota_default_description": "", - "password_enable_description": "", - "password_settings": "", - "password_settings_description": "", - "server_external_domain_settings": "", - "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", - "server_welcome_message": "", - "server_welcome_message_description": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "theme_custom_css_settings": "", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "thumbnail_generation_job_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", "trash_enabled_description": "Хогийн сав идэвхжүүлэх", "trash_number_of_days": "Хоногийн тоо", "trash_number_of_days_description": "Хогийн саванд хэд хоног хадгалаад бүр мөсөн устгах вэ", "trash_settings": "Хогийн савны тохиргоо", "trash_settings_description": "Хогийн савны тохиргоог өөрчлөх", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", "user_management": "Хэрэглэгчийн удирдлага", "user_password_has_been_reset": "Хэрэглэгчийн нууц үг шинээр тохируулагдлаа:", "user_restore_description": "<b>{user}</b>-н бүртгэл сэргэнэ.", - "user_settings": "Хэрэглэгчийн тохиргоо", - "user_settings_description": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job_description": "" + "user_settings": "Хэрэглэгчийн тохиргоо" }, - "admin_email": "", - "admin_password": "", "administration": "Админ", - "advanced": "", "advanced_settings_log_level_title": "Log level: {}", "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", "advanced_settings_prefer_remote_title": "Prefer remote images", @@ -239,8 +65,6 @@ "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting", "album_added": "Цомог нэмэгдлээ", - "album_added_notification_setting_description": "", - "album_cover_updated": "", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", "album_info_updated": "Цомгийн мэлээлэл шинэчлэгдлээ", @@ -254,8 +78,6 @@ "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", "album_thumbnail_shared_by": "Shared by {}", - "album_updated": "", - "album_updated_setting_description": "", "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", "album_viewer_appbar_share_err_delete": "Failed to delete album", "album_viewer_appbar_share_err_leave": "Failed to leave album", @@ -279,7 +101,6 @@ "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "app_settings": "Апп-н тохиргоо", - "appears_in": "", "archive": "Архив", "archive_or_unarchive_photo": "Зургийг архивт хийх эсвэл гаргах", "archive_page_no_archived_assets": "No archived assets found", @@ -299,21 +120,17 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", - "asset_offline": "", "asset_restored_successfully": "Asset restored successfully", "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", - "assets": "", "assets_deleted_permanently": "{} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", - "authorized_devices": "", "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", "automatic_endpoint_switching_title": "Automatic URL switching", - "back": "", "background_location_permission": "Background location permission", "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", @@ -378,8 +195,6 @@ "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", "backup_setting_subtitle": "Manage background and foreground upload settings", - "backward": "", - "blurred_background": "", "buy": "Immich худалдаж авах", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", @@ -405,11 +220,8 @@ "cancel": "Цуцлах", "cancel_search": "Хайлт цуцлах", "canceled": "Canceled", - "cannot_merge_people": "", - "cannot_update_the_description": "", "change_date": "Огноо өөрчлөх", "change_display_order": "Change display order", - "change_expiration_time": "", "change_location": "Байршил өөрчлөх", "change_name": "Нэр өөрчлөх", "change_name_successfully": "Нэр амжилттай өөрчлөгдлөө", @@ -419,17 +231,12 @@ "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", - "change_your_password": "", - "changed_visibility_successfully": "", "check_corrupt_asset_backup": "Check for corrupt asset backups", "check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", - "check_logs": "", "city": "Хот", "clear": "Цэвэрлэх", "clear_all": "Бүгдийг цэвэрлэх", - "clear_message": "", - "clear_value": "", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -438,20 +245,9 @@ "client_cert_remove_msg": "Client certificate is removed", "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", "client_cert_title": "SSL Client Certificate", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", "common_create_new_album": "Create new album", "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", "completed": "Completed", - "confirm": "", - "confirm_admin_password": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", "control_bottom_app_bar_album_info_shared": "{} items · Shared", "control_bottom_app_bar_create_new_album": "Create new album", "control_bottom_app_bar_delete_from_immich": "Delete from Immich", @@ -461,79 +257,27 @@ "control_bottom_app_bar_share_link": "Share Link", "control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_trash_from_immich": "Move to Trash", - "copied_image_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", "create_album_page_untitled": "Untitled", - "create_library": "", - "create_link": "", - "create_link_to_share": "", "create_new": "CREATE NEW", - "create_new_person": "", - "create_new_user": "", "create_shared_album_page_share_add_assets": "ADD ASSETS", "create_shared_album_page_share_select_photos": "Select Photos", - "create_user": "", - "created": "", "crop": "Crop", "curated_object_page_title": "Things", - "current_device": "", "current_server_address": "Current server address", - "custom_locale": "", - "custom_locale_description": "", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", "date_format": "E, LLL d, y • h:mm a", - "date_range": "", - "day": "", - "default_locale": "", - "default_locale_description": "", - "delete": "", - "delete_album": "", "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", "delete_dialog_ok_force": "Delete Anyway", "delete_dialog_title": "Delete Permanently", - "delete_key": "", - "delete_library": "", - "delete_link": "", "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", "delete_local_dialog_ok_force": "Delete Anyway", - "delete_shared_link": "", "delete_shared_link_dialog_title": "Delete Shared Link", - "delete_user": "", - "deleted_shared_link": "", - "description": "", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", - "details": "", - "direction": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "", - "done": "", - "download": "", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -547,86 +291,17 @@ "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", - "downloading": "", "downloading_media": "Downloading media", - "duration": "", - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", - "edit_link": "", - "edit_location": "", "edit_location_dialog_title": "Location", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", "empty_folder": "This folder is empty", "empty_trash": "Хогийн сав хоослох", - "enable": "", - "enabled": "", - "end_date": "", "enqueued": "Enqueued", "enter_wifi_name": "Enter WiFi name", - "error": "", "error_change_sort_album": "Failed to change album sort order", - "error_loading_image": "", "error_saving_image": "Error: {}", "errors": { - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_create_admin_account": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_user": "", "unable_to_empty_trash": "Хогийн савыг хоослож чадсангүй", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "Хогийн савнаас гаргаж чадсангүй", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_user": "" + "unable_to_restore_trash": "Хогийн савнаас гаргаж чадсангүй" }, "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", @@ -637,58 +312,32 @@ "exif_bottom_sheet_person_age_months": "Age {} months", "exif_bottom_sheet_person_age_year_months": "Age 1 year, {} months", "exif_bottom_sheet_person_age_years": "Age {}", - "exit_slideshow": "", - "expand_all": "", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", - "expire_after": "", - "expired": "", "explore": "Эрж олох", - "extension": "", - "external_libraries": "", "external_network": "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", "failed": "Failed", "failed_to_load_assets": "Failed to load assets", "failed_to_load_folder": "Failed to load folder", - "favorite": "", - "favorite_or_unfavorite_photo": "", "favorites": "Дуртай", "favorites_page_no_favorites": "No favorite assets found", - "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", "filter": "Filter", - "filter_people": "", - "fix_incorrect_match": "", "folder": "Folder", "folder_not_found": "Folder not found", "folders": "Folders", - "forward": "", - "general": "", - "get_help": "", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "getting_started": "", - "go_back": "", - "go_to_search": "", "grant_permission": "Grant permission", - "group_albums_by": "", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", - "has_quota": "", "header_settings_add_header_tip": "Add Header", "header_settings_field_validator_msg": "Value cannot be empty", "header_settings_header_name_input": "Header name", "header_settings_header_value_input": "Header value", "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", "headers_settings_tile_title": "Custom proxy headers", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_success": "Added {added} assets to album {album}.", @@ -703,57 +352,22 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", - "host": "", - "hour": "", "ignore_icloud_photos": "Ignore iCloud photos", "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", - "image": "", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_started": "Download Started", "image_viewer_page_state_provider_download_success": "Download Success", "image_viewer_page_state_provider_share_error": "Share Error", - "immich_logo": "", - "import_path": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", - "info": "", - "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" - }, "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", "invite_people": "Хүмүүс урих", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", "library": "Зургийн сан", - "library_options": "", "library_page_device_albums": "Albums on Device", "library_page_new_album": "New album", "library_page_sort_asset_count": "Number of assets", "library_page_sort_created": "Created date", "library_page_sort_last_modified": "Last modified", "library_page_sort_title": "Album title", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", "local_network": "Local network", "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", "location_permission": "Location permission", @@ -763,8 +377,6 @@ "location_picker_latitude_hint": "Enter your latitude here", "location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_hint": "Enter your longitude here", - "log_out": "", - "log_out_all_devices": "", "login_disabled": "Login has been disabled", "login_form_api_exception": "API exception. Please check the server URL and try again.", "login_form_back_button_text": "Back", @@ -784,21 +396,8 @@ "login_form_save_login": "Stay logged in", "login_form_server_empty": "Enter a server URL.", "login_form_server_error": "Could not connect to server.", - "login_has_been_disabled": "", "login_password_changed_error": "There was an error updating your password", "login_password_changed_success": "Password updated successfully", - "look": "", - "loop_videos": "", - "loop_videos_description": "", - "make": "", - "manage_shared_links": "", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", "map_assets_in_bound": "{} photo", "map_assets_in_bounds": "{} photos", "map_cannot_get_user_location": "Cannot get user's location", @@ -806,11 +405,9 @@ "map_location_picker_page_use_location": "Use this location", "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", "map_location_service_disabled_title": "Location Service disabled", - "map_marker_with_image": "", "map_no_assets_in_bounds": "No photos in this area", "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", "map_no_location_permission_title": "Location Permission denied", - "map_settings": "", "map_settings_dark_mode": "Dark mode", "map_settings_date_range_option_day": "Past 24 hours", "map_settings_date_range_option_days": "Past {} days", @@ -822,80 +419,27 @@ "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", "map_zoom_to_see_photos": "Zoom out to see photos", - "media_type": "", - "memories": "", "memories_all_caught_up": "All caught up", "memories_check_back_tomorrow": "Check back tomorrow for more memories", - "memories_setting_description": "", "memories_start_over": "Start Over", "memories_swipe_to_close": "Swipe up to close", "memories_year_ago": "A year ago", "memories_years_ago": "{} years ago", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", "monthly_title_text_date_format": "MMMM y", - "more": "", - "moved_to_trash": "", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", - "my_albums": "", - "name": "", - "name_or_nickname": "", "networking_settings": "Networking", "networking_subtitle": "Manage the server endpoint settings", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", - "no_albums_message": "", - "no_archived_assets_message": "", "no_assets_message": "Энд дарж та эхний зургаа хуулж үзэх үү", "no_assets_to_show": "No assets to show", - "no_exif_info_available": "", "no_explore_results_message": "Зураг хуулж оруулсаны дараа ашиглах боломжтой болно.", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", "not_selected": "Not selected", - "notes": "", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_list_tile_content": "Мэдэгдэл нээх эрх өгнө үү.\n", "notification_permission_list_tile_enable_button": "Мэдэгдэл нээх", "notification_permission_list_tile_title": "Мэдэгдлийн эрх", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "ok": "", - "oldest_first": "", "on_this_device": "On this device", - "online": "", "only_favorites": "Зөвхөн дуртай зурагнууд", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_empty_message": "Your photos are not yet shared with any partner.", @@ -904,29 +448,7 @@ "partner_page_select_partner": "Select partner", "partner_page_shared_to_title": "Shared to", "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", - "past_durations": { - "days": "", - "hours": "", - "years": "" - }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", "people": "Хүмүүс", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -935,24 +457,9 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", - "photos": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", "places": "Байршилууд", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", @@ -960,51 +467,13 @@ "profile_drawer_github": "GitHub", "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_deleted_assets": "", - "remove_from_album": "", "remove_from_favorites": "Дуртай зурагнуудаас хасах", - "remove_from_shared_link": "", "removed_from_favorites": "Дуртай зурагнуудаас хасагдсан", "removed_from_favorites_count": "Дуртай зурагнуудаас {count, plural, other {Removed #}} хасагдлаа", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", - "restore": "", - "restore_user": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", "save_to_gallery": "Save to gallery", - "saved_profile": "", - "saved_settings": "", - "say_something": "", "scaffold_body_error_occurred": "Error occurred", - "scan_all_libraries": "", - "scan_settings": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", "search_filter_apply": "Apply filter", "search_filter_camera_title": "Select camera type", "search_filter_date": "Date", @@ -1018,7 +487,6 @@ "search_filter_media_type": "Media Type", "search_filter_media_type_title": "Select media type", "search_filter_people_title": "Select people", - "search_for_existing_person": "", "search_no_more_result": "No more results", "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", @@ -1032,39 +500,16 @@ "search_page_view_all_button": "View all", "search_page_your_activity": "Your activity", "search_page_your_map": "Your Map", - "search_people": "", "search_places": "Байршил хайх", "search_result_page_new_search_hint": "New Search", - "search_state": "", "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", "search_suggestion_list_smart_search_hint_2": "m:your-search-term", - "search_timezone": "", - "search_type": "", "search_your_photos": "Зурагнуудаасаа хайлт хийх", - "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", - "select_photos": "", "select_user_for_sharing_page_err_album": "Failed to create album", - "selected": "", - "send_message": "", "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App Version", "server_info_box_server_url": "Server URL", "server_online": "Сервер Онлайн", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", "setting_image_viewer_original_title": "Load original image", @@ -1090,12 +535,9 @@ "setting_video_viewer_original_video_title": "Force original video", "settings": "Тохиргоо", "settings_require_restart": "Please restart Immich to apply this setting", - "settings_saved": "", - "share": "", "share_add_photos": "Add photos", "share_assets_selected": "{} selected", "share_dialog_preparing": "Preparing...", - "shared": "", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activity_remove_content": "Do you want to delete this activity?", "shared_album_activity_remove_title": "Delete Activity", @@ -1103,8 +545,6 @@ "shared_album_section_people_action_leave": "Remove user from album", "shared_album_section_people_action_remove_user": "Remove user from album", "shared_album_section_people_title": "PEOPLE", - "shared_by": "", - "shared_by_you": "", "shared_intent_upload_button_progress_text": "{} / {} Uploaded", "shared_link_app_bar_title": "Shared Links", "shared_link_clipboard_copied_massage": "Copied to clipboard", @@ -1134,58 +574,19 @@ "shared_link_individual_shared": "Individual shared", "shared_link_info_chip_metadata": "EXIF", "shared_link_manage_links": "Manage Shared links", - "shared_links": "", "shared_with_me": "Shared with me", "sharing": "Хуваалцах", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", - "sharing_sidebar_description": "", "sharing_silver_appbar_create_shared_album": "New shared album", "sharing_silver_appbar_share_partner": "Share with partner", - "show_album_options": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", - "show_metadata": "", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", "sign_out": "Гарах", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", - "stack": "", - "stack_selected_photos": "", - "stacktrace": "", - "start_date": "", - "state": "", - "status": "", - "stop_motion_photo": "", "storage": "Дискний багтаамж", - "storage_label": "", "storage_usage": "Нийт {available} боломжтойгоос {used} хэрэглэсэн", - "submit": "", - "suggestions": "", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", - "sync": "", "sync_albums": "Sync albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", - "template": "", - "theme": "", - "theme_selection": "", - "theme_selection_description": "", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", @@ -1199,15 +600,8 @@ "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", - "time_based_memories": "", - "timezone": "", - "toggle_settings": "", - "toggle_theme": "", - "total_usage": "", "trash": "Хогийн сав", - "trash_all": "", "trash_emptied": "Emptied trash", - "trash_no_results_message": "", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", "trash_page_info": "Trashed items will be permanently deleted after {} days", @@ -1215,50 +609,21 @@ "trash_page_restore_all": "Restore All", "trash_page_select_assets_btn": "Select assets", "trash_page_title": "Trash ({})", - "type": "", - "unarchive": "", - "unfavorite": "", - "unhide_person": "", - "unknown": "", - "unknown_year": "", - "unlink_oauth": "", - "unlinked_oauth_account": "", - "unselect_all": "", - "unstack": "", - "up_next": "", - "updated_password": "", "upload": "Зураг хуулах", - "upload_concurrency": "", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_title": "Upload Asset", "upload_to_immich": "Upload to Immich ({})", "uploading": "Uploading", - "url": "", - "usage": "", "use_current_connection": "use current connection", - "user": "", - "user_id": "", - "user_usage_detail": "", - "username": "", - "users": "", "utilities": "Багаж хэрэгсэл", - "validate": "", "validate_endpoint_error": "Please enter a valid URL", - "variables": "", - "version": "", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available 🎉", - "video": "", - "video_hover_setting_description": "", - "videos": "", "view_all": "Бүгдийг харах", "view_all_users": "Бүх хэрэглэгчийг харах", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack", diff --git a/i18n/ta.json b/i18n/ta.json index ab90eaadd5..ef27e57353 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -301,7 +301,6 @@ "transcoding_reference_frames_description": "கொடுக்கப்பட்ட சட்டகத்தை சுருக்கும்போது குறிப்பிட வேண்டிய பிரேம்களின் எண்ணிக்கை. அதிக மதிப்புகள் சுருக்க செயல்திறனை மேம்படுத்துகின்றன, ஆனால் குறியாக்கத்தை மெதுவாக்குகின்றன. 0 இந்த மதிப்பை தானாக அமைக்கிறது.", "transcoding_required_description": "ஏற்றுக்கொள்ளப்பட்ட வடிவத்தில் இல்லாத வீடியோக்கள் மட்டுமே", "transcoding_settings": "வீடியோ டிரான்ச்கோடிங் அமைப்புகள்", - "transcoding_settings_description": "", "transcoding_target_resolution": "இலக்கு தீர்மானம்", "transcoding_target_resolution_description": "அதிக தீர்மானங்கள் அதிக விவரங்களை பாதுகாக்க முடியும், ஆனால் குறியாக்க அதிக நேரம் எடுக்கும், பெரிய கோப்பு அளவுகளைக் கொண்டிருக்கலாம், மேலும் பயன்பாட்டு மறுமொழியைக் குறைக்கலாம்.", "transcoding_temporal_aq": "தம்போர்ல்", @@ -314,7 +313,6 @@ "transcoding_transcode_policy_description": "ஒரு வீடியோ எப்போது மாற்றப்பட வேண்டும் என்பதற்கான கொள்கை. எச்.டி.ஆர் வீடியோக்கள் எப்போதும் டிரான்ச்கோட் செய்யப்படும் (டிரான்ச்கோடிங் முடக்கப்பட்டிருந்தால் தவிர).", "transcoding_two_pass_encoding": "இரண்டு-பாச் குறியாக்கம்", "transcoding_two_pass_encoding_setting_description": "சிறந்த குறியாக்கப்பட்ட வீடியோக்களை உருவாக்க இரண்டு பாச்களில் டிரான்ச்கோட். மேக்ச் பிட்ரேட் இயக்கப்பட்டிருக்கும்போது (H.264 மற்றும் HEVC உடன் வேலை செய்ய இது தேவைப்படுகிறது), இந்த பயன்முறை அதிகபட்ச பிட்ரேட்டை அடிப்படையாகக் கொண்ட பிட்ரேட் வரம்பைப் பயன்படுத்துகிறது மற்றும் CRF ஐ புறக்கணிக்கிறது. VP9 ஐப் பொறுத்தவரை, அதிகபட்ச பிட்ரேட் முடக்கப்பட்டிருந்தால் CRF ஐப் பயன்படுத்தலாம்.", - "transcoding_video_codec": "", "transcoding_video_codec_description": "VP9 அதிக செயல்திறன் மற்றும் வலை பொருந்தக்கூடிய தன்மையைக் கொண்டுள்ளது, ஆனால் டிரான்ச்கோடிற்கு அதிக நேரம் எடுக்கும். HEVC இதேபோல் செயல்படுகிறது, ஆனால் குறைந்த வலை பொருந்தக்கூடிய தன்மையைக் கொண்டுள்ளது. H.264 பரவலாக இணக்கமானது மற்றும் டிரான்ச்கோடு விரைவானது, ஆனால் மிகப் பெரிய கோப்புகளை உருவாக்குகிறது. ஏ.வி 1 மிகவும் திறமையான கோடெக் ஆனால் பழைய சாதனங்களில் உதவி இல்லை.", "trash_enabled_description": "குப்பை அம்சங்களை இயக்கவும்", "trash_number_of_days": "நாட்களின் எண்ணிக்கை", diff --git a/i18n/th.json b/i18n/th.json index 39203d8fc9..b26b690a25 100644 --- a/i18n/th.json +++ b/i18n/th.json @@ -1700,7 +1700,6 @@ "stack_duplicates": "นำสิ่งที่ซ้ำมาซ้อนอยู่ด้วยกัน", "stack_select_one_photo": "เลือกรูปหลักหนึ่งรูปสำหรับรูปที่ซ้อนกันนี้", "stack_selected_photos": "ซ้อนรูปที่ถูกเลือก", - "stacktrace": "", "start": "เริ่มต้น", "start_date": "วันที่เริ่ม", "state": "รัฐ", diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index eb81dc267b..2179c9eb3c 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> + <uses-permission android:name="android.permission.USE_BIOMETRIC" /> <!-- Foreground service permission --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 752ded59ce..c1e5152d28 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,14 +1,14 @@ package app.alextran.immich -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine import androidx.annotation.NonNull +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity : FlutterActivity() { - override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) - // No need to set up method channel here as it's now handled in the plugin - } +class MainActivity : FlutterFragmentActivity() { + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(BackgroundServicePlugin()) + flutterEngine.plugins.add(HttpSSLOptionsPlugin()) + // No need to set up method channel here as it's now handled in the plugin + } } diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml index 0fdc703671..0a4dd28549 100644 --- a/mobile/android/app/src/main/res/values/styles.xml +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -1,22 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> - <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> - <!-- Show a splash screen on the activity. Automatically removed when + <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode + setting is off --> + <style name="LaunchTheme" parent="Theme.AppCompat.DayNight"> + <!-- Show a splash screen on the activity. Automatically removed when Flutter draws its first frame --> - <item name="android:windowBackground">@drawable/launch_background</item> - <item name="android:forceDarkAllowed">false</item> - <item name="android:windowFullscreen">false</item> - <item name="android:windowDrawsSystemBarBackgrounds">false</item> - <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> - </style> - <!-- Theme applied to the Android Window as soon as the process has started. + <item name="android:windowBackground">@drawable/launch_background</item> + <item name="android:forceDarkAllowed">false</item> + <item name="android:windowFullscreen">false</item> + <item name="android:windowDrawsSystemBarBackgrounds">false</item> + <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> + </style> + <!-- Theme applied to the Android Window as soon as the process has started. This theme determines the color of the Android Window while your Flutter UI initializes, as well as behind your Flutter UI while its running. This Theme is only used starting with V2 of Flutter's Android embedding. --> - <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> - <item name="android:windowBackground">?android:colorBackground</item> - </style> -</resources> + <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">?android:colorBackground</item> + </style> +</resources> \ No newline at end of file diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 9740d6aa52..537cdba8d8 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -44,6 +44,8 @@ PODS: - Flutter - flutter_native_splash (2.4.3): - Flutter + - flutter_secure_storage (6.0.0): + - Flutter - flutter_udid (0.0.1): - Flutter - SAMKeychain @@ -59,6 +61,9 @@ PODS: - Flutter - isar_flutter_libs (1.0.0): - Flutter + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS - MapLibre (6.5.0) - maplibre_gl (0.0.1): - Flutter @@ -130,6 +135,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) @@ -137,6 +143,7 @@ DEPENDENCIES: - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) + - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`) - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) @@ -178,6 +185,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_udid: :path: ".symlinks/plugins/flutter_udid/ios" flutter_web_auth_2: @@ -192,6 +201,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" isar_flutter_libs: :path: ".symlinks/plugins/isar_flutter_libs/ios" + local_auth_darwin: + :path: ".symlinks/plugins/local_auth_darwin/darwin" maplibre_gl: :path: ".symlinks/plugins/maplibre_gl/ios" native_video_player: @@ -233,6 +244,7 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 @@ -240,6 +252,7 @@ SPEC CHECKSUMS: image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 + local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e maplibre_gl: eab61cca6e1cfa9187249bacd3f08b51e8cd8ae9 native_video_player: b65c58951ede2f93d103a25366bdebca95081265 diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 38394f0f1b..e0c719fd0f 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -1,165 +1,167 @@ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> -<dict> - <key>AppGroupId</key> - <string>$(CUSTOM_GROUP_ID)</string> - <key>BGTaskSchedulerPermittedIdentifiers</key> - <array> - <string>app.alextran.immich.backgroundFetch</string> - <string>app.alextran.immich.backgroundProcessing</string> - </array> - <key>CADisableMinimumFrameDurationOnPhone</key> - <true/> - <key>CFBundleDevelopmentRegion</key> - <string>$(DEVELOPMENT_LANGUAGE)</string> - <key>CFBundleDisplayName</key> - <string>${PRODUCT_NAME}</string> - <key>CFBundleDocumentTypes</key> - <array> - <dict> - <key>CFBundleTypeName</key> - <string>ShareHandler</string> - <key>LSHandlerRank</key> - <string>Alternate</string> - <key>LSItemContentTypes</key> - <array> - <string>public.file-url</string> - <string>public.image</string> - <string>public.text</string> - <string>public.movie</string> - <string>public.url</string> - <string>public.data</string> - </array> - </dict> - </array> - <key>CFBundleExecutable</key> - <string>$(EXECUTABLE_NAME)</string> - <key>CFBundleIdentifier</key> - <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> - <key>CFBundleInfoDictionaryVersion</key> - <string>6.0</string> - <key>CFBundleLocalizations</key> - <array> - <string>en</string> - <string>ar</string> - <string>ca</string> - <string>cs</string> - <string>da</string> - <string>de</string> - <string>es</string> - <string>fi</string> - <string>fr</string> - <string>he</string> - <string>hi</string> - <string>hu</string> - <string>it</string> - <string>ja</string> - <string>ko</string> - <string>lv</string> - <string>mn</string> - <string>nb</string> - <string>nl</string> - <string>pl</string> - <string>pt</string> - <string>ro</string> - <string>ru</string> - <string>sk</string> - <string>sl</string> - <string>sr</string> - <string>sv</string> - <string>th</string> - <string>uk</string> - <string>vi</string> - <string>zh</string> - </array> - <key>CFBundleName</key> - <string>immich_mobile</string> - <key>CFBundlePackageType</key> - <string>APPL</string> - <key>CFBundleShortVersionString</key> - <string>1.132.3</string> - <key>CFBundleSignature</key> - <string>????</string> - <key>CFBundleURLTypes</key> - <array> - <dict> - <key>CFBundleTypeRole</key> - <string>Editor</string> - <key>CFBundleURLSchemes</key> - <array> - <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> - </array> - </dict> - </array> - <key>CFBundleVersion</key> - <string>205</string> - <key>FLTEnableImpeller</key> - <true/> - <key>ITSAppUsesNonExemptEncryption</key> - <false/> - <key>LSApplicationQueriesSchemes</key> - <array> - <string>https</string> - </array> - <key>LSRequiresIPhoneOS</key> - <true/> - <key>LSSupportsOpeningDocumentsInPlace</key> - <string>No</string> - <key>MGLMapboxMetricsEnabledSettingShownInApp</key> - <true/> - <key>NSAppTransportSecurity</key> - <dict> - <key>NSAllowsArbitraryLoads</key> - <true/> - </dict> - <key>NSCameraUsageDescription</key> - <string>We need to access the camera to let you take beautiful video using this app</string> - <key>NSLocationAlwaysAndWhenInUseUsageDescription</key> - <string>We require this permission to access the local WiFi name for background upload mechanism</string> - <key>NSLocationUsageDescription</key> - <string>We require this permission to access the local WiFi name</string> - <key>NSLocationWhenInUseUsageDescription</key> - <string>We require this permission to access the local WiFi name</string> - <key>NSMicrophoneUsageDescription</key> - <string>We need to access the microphone to let you take beautiful video using this app</string> - <key>NSPhotoLibraryAddUsageDescription</key> - <string>We need to manage backup your photos album</string> - <key>NSPhotoLibraryUsageDescription</key> - <string>We need to manage backup your photos album</string> - <key>NSUserActivityTypes</key> - <array> - <string>INSendMessageIntent</string> - </array> - <key>UIApplicationSupportsIndirectInputEvents</key> - <true/> - <key>UIBackgroundModes</key> - <array> - <string>fetch</string> - <string>processing</string> - </array> - <key>UILaunchStoryboardName</key> - <string>LaunchScreen</string> - <key>UIMainStoryboardFile</key> - <string>Main</string> - <key>UIStatusBarHidden</key> - <false/> - <key>UISupportedInterfaceOrientations</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> - <key>UISupportedInterfaceOrientations~ipad</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationPortraitUpsideDown</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> - <key>UIViewControllerBasedStatusBarAppearance</key> - <true/> - <key>io.flutter.embedded_views_preview</key> - <true/> -</dict> -</plist> + <dict> + <key>AppGroupId</key> + <string>$(CUSTOM_GROUP_ID)</string> + <key>BGTaskSchedulerPermittedIdentifiers</key> + <array> + <string>app.alextran.immich.backgroundFetch</string> + <string>app.alextran.immich.backgroundProcessing</string> + </array> + <key>CADisableMinimumFrameDurationOnPhone</key> + <true /> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>${PRODUCT_NAME}</string> + <key>CFBundleDocumentTypes</key> + <array> + <dict> + <key>CFBundleTypeName</key> + <string>ShareHandler</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.file-url</string> + <string>public.image</string> + <string>public.text</string> + <string>public.movie</string> + <string>public.url</string> + <string>public.data</string> + </array> + </dict> + </array> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleLocalizations</key> + <array> + <string>en</string> + <string>ar</string> + <string>ca</string> + <string>cs</string> + <string>da</string> + <string>de</string> + <string>es</string> + <string>fi</string> + <string>fr</string> + <string>he</string> + <string>hi</string> + <string>hu</string> + <string>it</string> + <string>ja</string> + <string>ko</string> + <string>lv</string> + <string>mn</string> + <string>nb</string> + <string>nl</string> + <string>pl</string> + <string>pt</string> + <string>ro</string> + <string>ru</string> + <string>sk</string> + <string>sl</string> + <string>sr</string> + <string>sv</string> + <string>th</string> + <string>uk</string> + <string>vi</string> + <string>zh</string> + </array> + <key>CFBundleName</key> + <string>immich_mobile</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>1.132.3</string> + <key>CFBundleSignature</key> + <string>????</string> + <key>CFBundleURLTypes</key> + <array> + <dict> + <key>CFBundleTypeRole</key> + <string>Editor</string> + <key>CFBundleURLSchemes</key> + <array> + <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> + </array> + </dict> + </array> + <key>CFBundleVersion</key> + <string>205</string> + <key>FLTEnableImpeller</key> + <true /> + <key>ITSAppUsesNonExemptEncryption</key> + <false /> + <key>LSApplicationQueriesSchemes</key> + <array> + <string>https</string> + </array> + <key>LSRequiresIPhoneOS</key> + <true /> + <key>LSSupportsOpeningDocumentsInPlace</key> + <string>No</string> + <key>MGLMapboxMetricsEnabledSettingShownInApp</key> + <true /> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSAllowsArbitraryLoads</key> + <true /> + </dict> + <key>NSCameraUsageDescription</key> + <string>We need to access the camera to let you take beautiful video using this app</string> + <key>NSLocationAlwaysAndWhenInUseUsageDescription</key> + <string>We require this permission to access the local WiFi name for background upload mechanism</string> + <key>NSLocationUsageDescription</key> + <string>We require this permission to access the local WiFi name</string> + <key>NSLocationWhenInUseUsageDescription</key> + <string>We require this permission to access the local WiFi name</string> + <key>NSMicrophoneUsageDescription</key> + <string>We need to access the microphone to let you take beautiful video using this app</string> + <key>NSPhotoLibraryAddUsageDescription</key> + <string>We need to manage backup your photos album</string> + <key>NSPhotoLibraryUsageDescription</key> + <string>We need to manage backup your photos album</string> + <key>NSUserActivityTypes</key> + <array> + <string>INSendMessageIntent</string> + </array> + <key>UIApplicationSupportsIndirectInputEvents</key> + <true /> + <key>UIBackgroundModes</key> + <array> + <string>fetch</string> + <string>processing</string> + </array> + <key>UILaunchStoryboardName</key> + <string>LaunchScreen</string> + <key>UIMainStoryboardFile</key> + <string>Main</string> + <key>UIStatusBarHidden</key> + <false /> + <key>UISupportedInterfaceOrientations</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UISupportedInterfaceOrientations~ipad</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationPortraitUpsideDown</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UIViewControllerBasedStatusBarAppearance</key> + <true /> + <key>io.flutter.embedded_views_preview</key> + <true /> + <key>NSFaceIDUsageDescription</key> + <string>We need to use FaceID to allow access to your locked folder</string> + </dict> +</plist> \ No newline at end of file diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index a91e0a715d..33683afd92 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -11,3 +11,6 @@ const int kSyncEventBatchSize = 5000; // Hash batch limits const int kBatchHashFileLimit = 128; const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB + +// Secure storage keys +const String kSecuredPinCode = "secured_pin_code"; diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 3a3bf9959a..a691263a1e 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -8,3 +8,5 @@ enum TextSearchType { filename, description, } + +enum AssetVisibilityEnum { timeline, hidden, archive, locked } diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 084cd1ee5d..d8d2bd23c3 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' @@ -45,7 +46,8 @@ class Asset { : remote.stack?.primaryAssetId, stackCount = remote.stack?.assetCount ?? 0, stackId = remote.stack?.id, - thumbhash = remote.thumbhash; + thumbhash = remote.thumbhash, + visibility = getVisibility(remote.visibility); Asset({ this.id = Isar.autoIncrement, @@ -71,6 +73,7 @@ class Asset { this.stackCount = 0, this.isOffline = false, this.thumbhash, + this.visibility = AssetVisibilityEnum.timeline, }); @ignore @@ -173,6 +176,9 @@ class Asset { int stackCount; + @Enumerated(EnumType.ordinal) + AssetVisibilityEnum visibility; + /// Returns null if the asset has no sync access to the exif info @ignore double? get aspectRatio { @@ -349,7 +355,8 @@ class Asset { a.thumbhash != thumbhash || stackId != a.stackId || stackCount != a.stackCount || - stackPrimaryAssetId == null && a.stackPrimaryAssetId != null; + stackPrimaryAssetId == null && a.stackPrimaryAssetId != null || + visibility != a.visibility; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -452,6 +459,7 @@ class Asset { String? stackPrimaryAssetId, int? stackCount, String? thumbhash, + AssetVisibilityEnum? visibility, }) => Asset( id: id ?? this.id, @@ -477,6 +485,7 @@ class Asset { stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId, stackCount: stackCount ?? this.stackCount, thumbhash: thumbhash ?? this.thumbhash, + visibility: visibility ?? this.visibility, ); Future<void> put(Isar db) async { @@ -541,8 +550,22 @@ class Asset { "isArchived": $isArchived, "isTrashed": $isTrashed, "isOffline": $isOffline, + "visibility": "$visibility", }"""; } + + static getVisibility(AssetVisibility visibility) { + switch (visibility) { + case AssetVisibility.timeline: + return AssetVisibilityEnum.timeline; + case AssetVisibility.archive: + return AssetVisibilityEnum.archive; + case AssetVisibility.hidden: + return AssetVisibilityEnum.hidden; + case AssetVisibility.locked: + return AssetVisibilityEnum.locked; + } + } } enum AssetType { diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 07eee4825e..b558690813 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -118,8 +118,14 @@ const AssetSchema = CollectionSchema( name: r'updatedAt', type: IsarType.dateTime, ), - r'width': PropertySchema( + r'visibility': PropertySchema( id: 20, + name: r'visibility', + type: IsarType.byte, + enumMap: _AssetvisibilityEnumValueMap, + ), + r'width': PropertySchema( + id: 21, name: r'width', type: IsarType.int, ) @@ -256,7 +262,8 @@ void _assetSerialize( writer.writeString(offsets[17], object.thumbhash); writer.writeByte(offsets[18], object.type.index); writer.writeDateTime(offsets[19], object.updatedAt); - writer.writeInt(offsets[20], object.width); + writer.writeByte(offsets[20], object.visibility.index); + writer.writeInt(offsets[21], object.width); } Asset _assetDeserialize( @@ -288,7 +295,10 @@ Asset _assetDeserialize( type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, updatedAt: reader.readDateTime(offsets[19]), - width: reader.readIntOrNull(offsets[20]), + visibility: + _AssetvisibilityValueEnumMap[reader.readByteOrNull(offsets[20])] ?? + AssetVisibilityEnum.timeline, + width: reader.readIntOrNull(offsets[21]), ); return object; } @@ -342,6 +352,9 @@ P _assetDeserializeProp<P>( case 19: return (reader.readDateTime(offset)) as P; case 20: + return (_AssetvisibilityValueEnumMap[reader.readByteOrNull(offset)] ?? + AssetVisibilityEnum.timeline) as P; + case 21: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -360,6 +373,18 @@ const _AssettypeValueEnumMap = { 2: AssetType.video, 3: AssetType.audio, }; +const _AssetvisibilityEnumValueMap = { + 'timeline': 0, + 'hidden': 1, + 'archive': 2, + 'locked': 3, +}; +const _AssetvisibilityValueEnumMap = { + 0: AssetVisibilityEnum.timeline, + 1: AssetVisibilityEnum.hidden, + 2: AssetVisibilityEnum.archive, + 3: AssetVisibilityEnum.locked, +}; Id _assetGetId(Asset object) { return object.id; @@ -2477,6 +2502,59 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> { }); } + QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityEqualTo( + AssetVisibilityEnum value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'visibility', + value: value, + )); + }); + } + + QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityGreaterThan( + AssetVisibilityEnum value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'visibility', + value: value, + )); + }); + } + + QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityLessThan( + AssetVisibilityEnum value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'visibility', + value: value, + )); + }); + } + + QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityBetween( + AssetVisibilityEnum lower, + AssetVisibilityEnum upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'visibility', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + QueryBuilder<Asset, Asset, QAfterFilterCondition> widthIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -2791,6 +2869,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> { }); } + QueryBuilder<Asset, Asset, QAfterSortBy> sortByVisibility() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.asc); + }); + } + + QueryBuilder<Asset, Asset, QAfterSortBy> sortByVisibilityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.desc); + }); + } + QueryBuilder<Asset, Asset, QAfterSortBy> sortByWidth() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'width', Sort.asc); @@ -3057,6 +3147,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> { }); } + QueryBuilder<Asset, Asset, QAfterSortBy> thenByVisibility() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.asc); + }); + } + + QueryBuilder<Asset, Asset, QAfterSortBy> thenByVisibilityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.desc); + }); + } + QueryBuilder<Asset, Asset, QAfterSortBy> thenByWidth() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'width', Sort.asc); @@ -3201,6 +3303,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> { }); } + QueryBuilder<Asset, Asset, QDistinct> distinctByVisibility() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'visibility'); + }); + } + QueryBuilder<Asset, Asset, QDistinct> distinctByWidth() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'width'); @@ -3335,6 +3443,13 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> { }); } + QueryBuilder<Asset, AssetVisibilityEnum, QQueryOperations> + visibilityProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'visibility'); + }); + } + QueryBuilder<Asset, int?, QQueryOperations> widthProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'width'); diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart index fe3320c9bb..a17e607d83 100644 --- a/mobile/lib/interfaces/asset_api.interface.dart +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; abstract interface class IAssetApiRepository { @@ -15,4 +16,9 @@ abstract interface class IAssetApiRepository { // Future<void> delete(String id); Future<List<Asset>> search({List<String> personIds = const []}); + + Future<void> updateVisibility( + List<String> list, + AssetVisibilityEnum visibility, + ); } diff --git a/mobile/lib/interfaces/auth_api.interface.dart b/mobile/lib/interfaces/auth_api.interface.dart index 0a4b235ff3..bb9a8b5a2c 100644 --- a/mobile/lib/interfaces/auth_api.interface.dart +++ b/mobile/lib/interfaces/auth_api.interface.dart @@ -6,4 +6,9 @@ abstract interface class IAuthApiRepository { Future<void> logout(); Future<void> changePassword(String newPassword); + + Future<bool> unlockPinCode(String pinCode); + Future<void> lockPinCode(); + + Future<void> setupPinCode(String pinCode); } diff --git a/mobile/lib/interfaces/biometric.interface.dart b/mobile/lib/interfaces/biometric.interface.dart new file mode 100644 index 0000000000..e410c8e26e --- /dev/null +++ b/mobile/lib/interfaces/biometric.interface.dart @@ -0,0 +1,6 @@ +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; + +abstract interface class IBiometricRepository { + Future<BiometricStatus> getStatus(); + Future<bool> authenticate(String? message); +} diff --git a/mobile/lib/interfaces/secure_storage.interface.dart b/mobile/lib/interfaces/secure_storage.interface.dart new file mode 100644 index 0000000000..81230e0abd --- /dev/null +++ b/mobile/lib/interfaces/secure_storage.interface.dart @@ -0,0 +1,5 @@ +abstract interface class ISecureStorageRepository { + Future<String?> read(String key); + Future<void> write(String key, String value); + Future<void> delete(String key); +} diff --git a/mobile/lib/interfaces/timeline.interface.dart b/mobile/lib/interfaces/timeline.interface.dart index bc486a785f..3a4cce3cb6 100644 --- a/mobile/lib/interfaces/timeline.interface.dart +++ b/mobile/lib/interfaces/timeline.interface.dart @@ -31,4 +31,9 @@ abstract class ITimelineRepository { ); Stream<RenderList> watchAssetSelectionTimeline(String userId); + + Stream<RenderList> watchLockedTimeline( + String userId, + GroupAssetsBy groupAssetsBy, + ); } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c39d5e3a66..3c7c1fbe4d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/routing/tab_navigation_observer.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; @@ -219,7 +219,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> ), routeInformationParser: router.defaultRouteParser(), routerDelegate: router.delegate( - navigatorObservers: () => [TabNavigationObserver(ref: ref)], + navigatorObservers: () => [AppNavigationObserver(ref: ref)], ), ), ), diff --git a/mobile/lib/models/auth/biometric_status.model.dart b/mobile/lib/models/auth/biometric_status.model.dart new file mode 100644 index 0000000000..3057f06e9c --- /dev/null +++ b/mobile/lib/models/auth/biometric_status.model.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; +import 'package:local_auth/local_auth.dart'; + +class BiometricStatus { + final List<BiometricType> availableBiometrics; + final bool canAuthenticate; + + const BiometricStatus({ + required this.availableBiometrics, + required this.canAuthenticate, + }); + + @override + String toString() => + 'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)'; + + BiometricStatus copyWith({ + List<BiometricType>? availableBiometrics, + bool? canAuthenticate, + }) { + return BiometricStatus( + availableBiometrics: availableBiometrics ?? this.availableBiometrics, + canAuthenticate: canAuthenticate ?? this.canAuthenticate, + ); + } + + @override + bool operator ==(covariant BiometricStatus other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.availableBiometrics, availableBiometrics) && + other.canAuthenticate == canAuthenticate; + } + + @override + int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode; +} diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 1dc336d204..50126ed1a8 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -140,6 +140,19 @@ class QuickAccessButtons extends ConsumerWidget { ), onTap: () => context.pushRoute(FolderRoute()), ), + ListTile( + leading: const Icon( + Icons.lock_outline_rounded, + size: 26, + ), + title: Text( + 'locked_folder'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + onTap: () => context.pushRoute(const LockedRoute()), + ), ListTile( leading: const Icon( Icons.group_outlined, diff --git a/mobile/lib/pages/library/locked/locked.page.dart b/mobile/lib/pages/library/locked/locked.page.dart new file mode 100644 index 0000000000..eef12a7107 --- /dev/null +++ b/mobile/lib/pages/library/locked/locked.page.dart @@ -0,0 +1,95 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; + +@RoutePage() +class LockedPage extends HookConsumerWidget { + const LockedPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appLifeCycle = useAppLifecycleState(); + final showOverlay = useState(false); + final authProviderNotifier = ref.read(authProvider.notifier); + // lock the page when it is destroyed + useEffect( + () { + return () { + authProviderNotifier.lockPinCode(); + }; + }, + [], + ); + + useEffect( + () { + if (context.mounted) { + if (appLifeCycle == AppLifecycleState.resumed) { + showOverlay.value = false; + } else { + showOverlay.value = true; + } + } + + return null; + }, + [appLifeCycle], + ); + + return Scaffold( + appBar: ref.watch(multiselectProvider) ? null : const LockPageAppBar(), + body: showOverlay.value + ? const SizedBox() + : MultiselectGrid( + renderListProvider: lockedTimelineProvider, + topWidget: Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + 'no_locked_photos_message'.tr(), + style: context.textTheme.labelLarge, + ), + ), + ), + editEnabled: false, + favoriteEnabled: false, + unfavorite: false, + archiveEnabled: false, + stackEnabled: false, + unarchive: false, + ), + ); + } +} + +class LockPageAppBar extends ConsumerWidget implements PreferredSizeWidget { + const LockPageAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AppBar( + leading: IconButton( + onPressed: () { + ref.read(authProvider.notifier).lockPinCode(); + context.maybePop(); + }, + icon: const Icon(Icons.arrow_back_ios_rounded), + ), + centerTitle: true, + automaticallyImplyLeading: false, + title: const Text( + 'locked_folder', + ).tr(), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/mobile/lib/pages/library/locked/pin_auth.page.dart b/mobile/lib/pages/library/locked/pin_auth.page.dart new file mode 100644 index 0000000000..cca0e3b7ac --- /dev/null +++ b/mobile/lib/pages/library/locked/pin_auth.page.dart @@ -0,0 +1,127 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/local_auth.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/forms/pin_registration_form.dart'; +import 'package:immich_mobile/widgets/forms/pin_verification_form.dart'; + +@RoutePage() +class PinAuthPage extends HookConsumerWidget { + final bool createPinCode; + + const PinAuthPage({super.key, this.createPinCode = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAuthState = ref.watch(localAuthProvider); + final showPinRegistrationForm = useState(createPinCode); + + Future<void> registerBiometric(String pinCode) async { + final isRegistered = + await ref.read(localAuthProvider.notifier).registerBiometric( + context, + pinCode, + ); + + if (isRegistered) { + context.showSnackBar( + SnackBar( + content: Text( + 'biometric_auth_enabled'.tr(), + style: context.textTheme.labelLarge, + ), + duration: const Duration(seconds: 3), + backgroundColor: context.colorScheme.primaryContainer, + ), + ); + + context.replaceRoute(const LockedRoute()); + } + } + + enableBiometricAuth() { + showDialog( + context: context, + builder: (buildContext) { + return SimpleDialog( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PinVerificationForm( + description: 'enable_biometric_auth_description'.tr(), + onSuccess: (pinCode) { + Navigator.pop(buildContext); + registerBiometric(pinCode); + }, + autoFocus: true, + icon: Icons.fingerprint_rounded, + successIcon: Icons.fingerprint_rounded, + ), + ], + ), + ), + ], + ); + }, + ); + } + + return Scaffold( + appBar: AppBar( + title: Text('locked_folder'.tr()), + ), + body: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.only(top: 36.0), + child: showPinRegistrationForm.value + ? Center( + child: PinRegistrationForm( + onDone: () => showPinRegistrationForm.value = false, + ), + ) + : Column( + children: [ + Center( + child: PinVerificationForm( + autoFocus: true, + onSuccess: (_) => + context.replaceRoute(const LockedRoute()), + ), + ), + const SizedBox(height: 24), + if (localAuthState.canAuthenticate) ...[ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: TextButton.icon( + icon: const Icon( + Icons.fingerprint, + size: 28, + ), + onPressed: enableBiometricAuth, + label: Text( + 'use_biometric'.tr(), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + fontSize: 18, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a35ab10bf3..5b77da90f3 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -170,6 +171,13 @@ class AssetNotifier extends StateNotifier<bool> { status ??= !assets.every((a) => a.isArchived); return _assetService.changeArchiveStatus(assets, status); } + + Future<void> setLockedView( + List<Asset> selection, + AssetVisibilityEnum visibility, + ) { + return _assetService.setVisibility(selection, visibility); + } } final assetDetailProvider = diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 297b3a99fe..5207858f99 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; @@ -11,6 +12,7 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/utils/hash.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -20,6 +22,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) { ref.watch(authServiceProvider), ref.watch(apiServiceProvider), ref.watch(userServiceProvider), + ref.watch(secureStorageServiceProvider), ); }); @@ -27,12 +30,17 @@ class AuthNotifier extends StateNotifier<AuthState> { final AuthService _authService; final ApiService _apiService; final UserService _userService; + final SecureStorageService _secureStorageService; final _log = Logger("AuthenticationNotifier"); static const Duration _timeoutDuration = Duration(seconds: 7); - AuthNotifier(this._authService, this._apiService, this._userService) - : super( + AuthNotifier( + this._authService, + this._apiService, + this._userService, + this._secureStorageService, + ) : super( AuthState( deviceId: "", userId: "", @@ -67,6 +75,7 @@ class AuthNotifier extends StateNotifier<AuthState> { Future<void> logout() async { try { + await _secureStorageService.delete(kSecuredPinCode); await _authService.logout(); } finally { await _cleanUp(); @@ -188,4 +197,16 @@ class AuthNotifier extends StateNotifier<AuthState> { Future<String?> setOpenApiServiceEndpoint() { return _authService.setOpenApiServiceEndpoint(); } + + Future<bool> unlockPinCode(String pinCode) { + return _authService.unlockPinCode(pinCode); + } + + Future<void> lockPinCode() { + return _authService.lockPinCode(); + } + + Future<void> setupPinCode(String pinCode) { + return _authService.setupPinCode(pinCode); + } } diff --git a/mobile/lib/providers/local_auth.provider.dart b/mobile/lib/providers/local_auth.provider.dart new file mode 100644 index 0000000000..6f7ca5eb71 --- /dev/null +++ b/mobile/lib/providers/local_auth.provider.dart @@ -0,0 +1,97 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; +import 'package:immich_mobile/services/local_auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:logging/logging.dart'; + +final localAuthProvider = + StateNotifierProvider<LocalAuthNotifier, BiometricStatus>((ref) { + return LocalAuthNotifier( + ref.watch(localAuthServiceProvider), + ref.watch(secureStorageServiceProvider), + ); +}); + +class LocalAuthNotifier extends StateNotifier<BiometricStatus> { + final LocalAuthService _localAuthService; + final SecureStorageService _secureStorageService; + + final _log = Logger("LocalAuthNotifier"); + + LocalAuthNotifier(this._localAuthService, this._secureStorageService) + : super( + const BiometricStatus( + availableBiometrics: [], + canAuthenticate: false, + ), + ) { + _localAuthService.getStatus().then((value) { + state = state.copyWith( + canAuthenticate: value.canAuthenticate, + availableBiometrics: value.availableBiometrics, + ); + }); + } + + Future<bool> registerBiometric(BuildContext context, String pinCode) async { + final isAuthenticated = + await authenticate(context, 'Authenticate to enable biometrics'); + + if (!isAuthenticated) { + return false; + } + + await _secureStorageService.write(kSecuredPinCode, pinCode); + + return true; + } + + Future<bool> authenticate(BuildContext context, String? message) async { + String errorMessage = ""; + + try { + return await _localAuthService.authenticate(message); + } on PlatformException catch (error) { + switch (error.code) { + case "NotEnrolled": + _log.warning("User is not enrolled in biometrics"); + errorMessage = "biometric_no_options".tr(); + break; + case "NotAvailable": + _log.warning("Biometric authentication is not available"); + errorMessage = "biometric_not_available".tr(); + break; + case "LockedOut": + _log.warning("User is locked out of biometric authentication"); + errorMessage = "biometric_locked_out".tr(); + break; + default: + _log.warning("Failed to authenticate with unknown reason"); + errorMessage = 'failed_to_authenticate'.tr(); + } + } catch (error) { + _log.warning("Error during authentication: $error"); + errorMessage = 'failed_to_authenticate'.tr(); + } finally { + if (errorMessage.isNotEmpty) { + context.showSnackBar( + SnackBar( + content: Text( + errorMessage, + style: context.textTheme.labelLarge, + ), + duration: const Duration(seconds: 3), + backgroundColor: context.colorScheme.errorContainer, + ), + ); + } + } + + return false; + } +} diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart new file mode 100644 index 0000000000..a5b903e312 --- /dev/null +++ b/mobile/lib/providers/routes.provider.dart @@ -0,0 +1,3 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final inLockedViewProvider = StateProvider<bool>((ref) => false); diff --git a/mobile/lib/providers/secure_storage.provider.dart b/mobile/lib/providers/secure_storage.provider.dart new file mode 100644 index 0000000000..0194e527e9 --- /dev/null +++ b/mobile/lib/providers/secure_storage.provider.dart @@ -0,0 +1,10 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final secureStorageProvider = + StateNotifierProvider<SecureStorageProvider, void>((ref) { + return SecureStorageProvider(); +}); + +class SecureStorageProvider extends StateNotifier<void> { + SecureStorageProvider() : super(null); +} diff --git a/mobile/lib/providers/timeline.provider.dart b/mobile/lib/providers/timeline.provider.dart index f857d8aa6c..b2c763cdfa 100644 --- a/mobile/lib/providers/timeline.provider.dart +++ b/mobile/lib/providers/timeline.provider.dart @@ -73,3 +73,8 @@ final assetsTimelineProvider = null, ); }); + +final lockedTimelineProvider = StreamProvider<RenderList>((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchLockedTimelineProvider(); +}); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index f4fcd8a6dd..45442c2d61 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -48,4 +49,27 @@ class AssetApiRepository extends ApiRepository implements IAssetApiRepository { } return result; } + + @override + Future<void> updateVisibility( + List<String> ids, + AssetVisibilityEnum visibility, + ) async { + return _api.updateAssets( + AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)), + ); + } + + _mapVisibility(AssetVisibilityEnum visibility) { + switch (visibility) { + case AssetVisibilityEnum.timeline: + return AssetVisibility.timeline; + case AssetVisibilityEnum.hidden: + return AssetVisibility.hidden; + case AssetVisibilityEnum.locked: + return AssetVisibility.locked; + case AssetVisibilityEnum.archive: + return AssetVisibility.archive; + } + } } diff --git a/mobile/lib/repositories/auth_api.repository.dart b/mobile/lib/repositories/auth_api.repository.dart index f3a1d52de3..4015ffd7bc 100644 --- a/mobile/lib/repositories/auth_api.repository.dart +++ b/mobile/lib/repositories/auth_api.repository.dart @@ -55,4 +55,26 @@ class AuthApiRepository extends ApiRepository implements IAuthApiRepository { userId: dto.userId, ); } + + @override + Future<bool> unlockPinCode(String pinCode) async { + try { + await _apiService.authenticationApi + .unlockAuthSession(SessionUnlockDto(pinCode: pinCode)); + return true; + } catch (_) { + return false; + } + } + + @override + Future<void> setupPinCode(String pinCode) { + return _apiService.authenticationApi + .setupPinCode(PinCodeSetupDto(pinCode: pinCode)); + } + + @override + Future<void> lockPinCode() { + return _apiService.authenticationApi.lockAuthSession(); + } } diff --git a/mobile/lib/repositories/biometric.repository.dart b/mobile/lib/repositories/biometric.repository.dart new file mode 100644 index 0000000000..588fa44797 --- /dev/null +++ b/mobile/lib/repositories/biometric.repository.dart @@ -0,0 +1,35 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/biometric.interface.dart'; +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; +import 'package:local_auth/local_auth.dart'; + +final biometricRepositoryProvider = + Provider((ref) => BiometricRepository(LocalAuthentication())); + +class BiometricRepository implements IBiometricRepository { + final LocalAuthentication _localAuth; + + BiometricRepository(this._localAuth); + + @override + Future<BiometricStatus> getStatus() async { + final bool canAuthenticateWithBiometrics = + await _localAuth.canCheckBiometrics; + final bool canAuthenticate = + canAuthenticateWithBiometrics || await _localAuth.isDeviceSupported(); + final availableBiometric = await _localAuth.getAvailableBiometrics(); + + return BiometricStatus( + canAuthenticate: canAuthenticate, + availableBiometrics: availableBiometric, + ); + } + + @override + Future<bool> authenticate(String? message) async { + return _localAuth.authenticate( + localizedReason: message ?? 'please_auth_to_access'.tr(), + ); + } +} diff --git a/mobile/lib/repositories/secure_storage.repository.dart b/mobile/lib/repositories/secure_storage.repository.dart new file mode 100644 index 0000000000..fc641bcc91 --- /dev/null +++ b/mobile/lib/repositories/secure_storage.repository.dart @@ -0,0 +1,27 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/secure_storage.interface.dart'; + +final secureStorageRepositoryProvider = + Provider((ref) => SecureStorageRepository(const FlutterSecureStorage())); + +class SecureStorageRepository implements ISecureStorageRepository { + final FlutterSecureStorage _secureStorage; + + SecureStorageRepository(this._secureStorage); + + @override + Future<String?> read(String key) { + return _secureStorage.read(key: key); + } + + @override + Future<void> write(String key, String value) { + return _secureStorage.write(key: key, value: value); + } + + @override + Future<void> delete(String key) { + return _secureStorage.delete(key: key); + } +} diff --git a/mobile/lib/repositories/timeline.repository.dart b/mobile/lib/repositories/timeline.repository.dart index 319ce3e5b4..f48b749767 100644 --- a/mobile/lib/repositories/timeline.repository.dart +++ b/mobile/lib/repositories/timeline.repository.dart @@ -45,8 +45,8 @@ class TimelineRepository extends DatabaseRepository .where() .ownerIdEqualToAnyChecksum(fastHash(userId)) .filter() - .isArchivedEqualTo(true) .isTrashedEqualTo(false) + .visibilityEqualTo(AssetVisibilityEnum.archive) .sortByFileCreatedAtDesc(); return _watchRenderList(query, GroupAssetsBy.none); @@ -59,6 +59,8 @@ class TimelineRepository extends DatabaseRepository .ownerIdEqualToAnyChecksum(fastHash(userId)) .filter() .isFavoriteEqualTo(true) + .not() + .visibilityEqualTo(AssetVisibilityEnum.locked) .isTrashedEqualTo(false) .sortByFileCreatedAtDesc(); @@ -94,8 +96,8 @@ class TimelineRepository extends DatabaseRepository Stream<RenderList> watchAllVideosTimeline() { final query = db.assets .filter() - .isArchivedEqualTo(false) .isTrashedEqualTo(false) + .visibilityEqualTo(AssetVisibilityEnum.timeline) .typeEqualTo(AssetType.video) .sortByFileCreatedAtDesc(); @@ -111,9 +113,9 @@ class TimelineRepository extends DatabaseRepository .where() .ownerIdEqualToAnyChecksum(fastHash(userId)) .filter() - .isArchivedEqualTo(false) .isTrashedEqualTo(false) .stackPrimaryAssetIdIsNull() + .visibilityEqualTo(AssetVisibilityEnum.timeline) .sortByFileCreatedAtDesc(); return _watchRenderList(query, groupAssetByOption); @@ -129,8 +131,8 @@ class TimelineRepository extends DatabaseRepository .where() .anyOf(isarUserIds, (qb, id) => qb.ownerIdEqualToAnyChecksum(id)) .filter() - .isArchivedEqualTo(false) .isTrashedEqualTo(false) + .visibilityEqualTo(AssetVisibilityEnum.timeline) .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); return _watchRenderList(query, groupAssetByOption); @@ -151,6 +153,7 @@ class TimelineRepository extends DatabaseRepository .remoteIdIsNotNull() .filter() .ownerIdEqualTo(fastHash(userId)) + .visibilityEqualTo(AssetVisibilityEnum.timeline) .isTrashedEqualTo(false) .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); @@ -158,6 +161,22 @@ class TimelineRepository extends DatabaseRepository return _watchRenderList(query, GroupAssetsBy.none); } + @override + Stream<RenderList> watchLockedTimeline( + String userId, + GroupAssetsBy getGroupByOption, + ) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(fastHash(userId)) + .filter() + .visibilityEqualTo(AssetVisibilityEnum.locked) + .isTrashedEqualTo(false) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, getGroupByOption); + } + Stream<RenderList> _watchRenderList( QueryBuilder<Asset, Asset, QAfterSortBy> query, GroupAssetsBy groupAssetsBy, diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart new file mode 100644 index 0000000000..44662c0b8b --- /dev/null +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -0,0 +1,52 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class AppNavigationObserver extends AutoRouterObserver { + /// Riverpod Instance + final WidgetRef ref; + + AppNavigationObserver({ + required this.ref, + }); + + @override + Future<void> didChangeTabRoute( + TabPageRoute route, + TabPageRoute previousRoute, + ) async { + Future( + () => ref.read(inLockedViewProvider.notifier).state = false, + ); + } + + @override + void didPush(Route route, Route? previousRoute) { + _handleLockedViewState(route, previousRoute); + } + + _handleLockedViewState(Route route, Route? previousRoute) { + final isInLockedView = ref.read(inLockedViewProvider); + final isFromLockedViewToDetailView = + route.settings.name == GalleryViewerRoute.name && + previousRoute?.settings.name == LockedRoute.name; + + final isFromDetailViewToInfoPanelView = route.settings.name == null && + previousRoute?.settings.name == GalleryViewerRoute.name && + isInLockedView; + + if (route.settings.name == LockedRoute.name || + isFromLockedViewToDetailView || + isFromDetailViewToInfoPanelView) { + Future( + () => ref.read(inLockedViewProvider.notifier).state = true, + ); + } else { + Future( + () => ref.read(inLockedViewProvider.notifier).state = false, + ); + } + } +} diff --git a/mobile/lib/routing/locked_guard.dart b/mobile/lib/routing/locked_guard.dart new file mode 100644 index 0000000000..d731c7942c --- /dev/null +++ b/mobile/lib/routing/locked_guard.dart @@ -0,0 +1,89 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/services.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/routing/router.dart'; + +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/local_auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:local_auth/error_codes.dart' as auth_error; +import 'package:logging/logging.dart'; +// ignore: import_rule_openapi +import 'package:openapi/api.dart'; + +class LockedGuard extends AutoRouteGuard { + final ApiService _apiService; + final SecureStorageService _secureStorageService; + final LocalAuthService _localAuth; + final _log = Logger("AuthGuard"); + + LockedGuard( + this._apiService, + this._secureStorageService, + this._localAuth, + ); + + @override + void onNavigation(NavigationResolver resolver, StackRouter router) async { + final authStatus = await _apiService.authenticationApi.getAuthStatus(); + + if (authStatus == null) { + resolver.next(false); + return; + } + + /// Check if a pincode has been created but this user. Show the form to create if not exist + if (!authStatus.pinCode) { + router.push(PinAuthRoute(createPinCode: true)); + } + + if (authStatus.isElevated) { + resolver.next(true); + return; + } + + /// Check if the user has the pincode saved in secure storage, meaning + /// the user has enabled the biometric authentication + final securePinCode = await _secureStorageService.read(kSecuredPinCode); + if (securePinCode == null) { + router.push(PinAuthRoute()); + return; + } + + try { + final bool isAuth = await _localAuth.authenticate(); + + if (!isAuth) { + resolver.next(false); + return; + } + + await _apiService.authenticationApi.unlockAuthSession( + SessionUnlockDto(pinCode: securePinCode), + ); + + resolver.next(true); + } on PlatformException catch (error) { + switch (error.code) { + case auth_error.notAvailable: + _log.severe("notAvailable: $error"); + break; + case auth_error.notEnrolled: + _log.severe("not enrolled"); + break; + default: + _log.severe("error"); + break; + } + + resolver.next(false); + } on ApiException { + // PIN code has changed, need to re-enter to access + await _secureStorageService.delete(kSecuredPinCode); + router.push(PinAuthRoute()); + } catch (error) { + _log.severe("Failed to access locked page", error); + resolver.next(false); + } + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index fcfe7e59bd..317ce7cc54 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -39,6 +39,8 @@ import 'package:immich_mobile/pages/library/favorite.page.dart'; import 'package:immich_mobile/pages/library/folder/folder.page.dart'; import 'package:immich_mobile/pages/library/library.page.dart'; import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/locked/locked.page.dart'; +import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart'; import 'package:immich_mobile/pages/library/partner/partner.page.dart'; import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; @@ -67,24 +69,41 @@ import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/backup_permission_guard.dart'; import 'package:immich_mobile/routing/custom_transition_builders.dart'; import 'package:immich_mobile/routing/duplicate_guard.dart'; +import 'package:immich_mobile/routing/locked_guard.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/local_auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; +final appRouterProvider = Provider( + (ref) => AppRouter( + ref.watch(apiServiceProvider), + ref.watch(galleryPermissionNotifier.notifier), + ref.watch(secureStorageServiceProvider), + ref.watch(localAuthServiceProvider), + ), +); + @AutoRouterConfig(replaceInRouteName: 'Page,Route') class AppRouter extends RootStackRouter { late final AuthGuard _authGuard; late final DuplicateGuard _duplicateGuard; late final BackupPermissionGuard _backupPermissionGuard; + late final LockedGuard _lockedGuard; AppRouter( ApiService apiService, GalleryPermissionNotifier galleryPermissionNotifier, + SecureStorageService secureStorageService, + LocalAuthService localAuthService, ) { _authGuard = AuthGuard(apiService); _duplicateGuard = DuplicateGuard(); + _lockedGuard = + LockedGuard(apiService, secureStorageService, localAuthService); _backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier); } @@ -289,12 +308,13 @@ class AppRouter extends RootStackRouter { page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: LockedRoute.page, + guards: [_authGuard, _lockedGuard, _duplicateGuard], + ), + AutoRoute( + page: PinAuthRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } - -final appRouterProvider = Provider( - (ref) => AppRouter( - ref.watch(apiServiceProvider), - ref.watch(galleryPermissionNotifier.notifier), - ), -); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 01ab3fa13c..da488779e6 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -956,6 +956,25 @@ class LocalAlbumsRoute extends PageRouteInfo<void> { ); } +/// generated route for +/// [LockedPage] +class LockedRoute extends PageRouteInfo<void> { + const LockedRoute({List<PageRouteInfo>? children}) + : super( + LockedRoute.name, + initialChildren: children, + ); + + static const String name = 'LockedRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LockedPage(); + }, + ); +} + /// generated route for /// [LoginPage] class LoginRoute extends PageRouteInfo<void> { @@ -1359,6 +1378,53 @@ class PhotosRoute extends PageRouteInfo<void> { ); } +/// generated route for +/// [PinAuthPage] +class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> { + PinAuthRoute({ + Key? key, + bool createPinCode = false, + List<PageRouteInfo>? children, + }) : super( + PinAuthRoute.name, + args: PinAuthRouteArgs( + key: key, + createPinCode: createPinCode, + ), + initialChildren: children, + ); + + static const String name = 'PinAuthRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = + data.argsAs<PinAuthRouteArgs>(orElse: () => const PinAuthRouteArgs()); + return PinAuthPage( + key: args.key, + createPinCode: args.createPinCode, + ); + }, + ); +} + +class PinAuthRouteArgs { + const PinAuthRouteArgs({ + this.key, + this.createPinCode = false, + }); + + final Key? key; + + final bool createPinCode; + + @override + String toString() { + return 'PinAuthRouteArgs{key: $key, createPinCode: $createPinCode}'; + } +} + /// generated route for /// [PlacesCollectionPage] class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> { diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart deleted file mode 100644 index d95820885e..0000000000 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; - -class TabNavigationObserver extends AutoRouterObserver { - /// Riverpod Instance - final WidgetRef ref; - - TabNavigationObserver({ - required this.ref, - }); - - @override - Future<void> didChangeTabRoute( - TabPageRoute route, - TabPageRoute previousRoute, - ) async { - if (route.name == 'HomeRoute') { - ref.invalidate(memoryFutureProvider); - Future(() => ref.read(assetProvider.notifier).getAllAsset()); - - // Update user info - try { - ref.read(userServiceProvider).refreshMyUser(); - ref.read(serverInfoProvider.notifier).getServerVersion(); - } catch (e) { - debugPrint("Error refreshing user info $e"); - } - } - } -} diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 8a24e72fbe..a52d6e6368 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -239,6 +240,9 @@ class AssetService { for (var element in assets) { element.isArchived = isArchived; + element.visibility = isArchived + ? AssetVisibilityEnum.archive + : AssetVisibilityEnum.timeline; } await _syncService.upsertAssetsWithExif(assets); @@ -458,6 +462,7 @@ class AssetService { bool shouldDeletePermanently = false, }) async { final candidates = assets.where((a) => a.isRemote); + if (candidates.isEmpty) { return; } @@ -475,6 +480,7 @@ class AssetService { .where((asset) => asset.storage == AssetState.merged) .map((asset) { asset.remoteId = null; + asset.visibility = AssetVisibilityEnum.timeline; return asset; }) : assets.where((asset) => asset.isRemote).map((asset) { @@ -529,4 +535,21 @@ class AssetService { final me = _userService.getMyUser(); return _assetRepository.getMotionAssets(me.id); } + + Future<void> setVisibility( + List<Asset> assets, + AssetVisibilityEnum visibility, + ) async { + await _assetApiRepository.updateVisibility( + assets.map((asset) => asset.remoteId!).toList(), + visibility, + ); + + final updatedAssets = assets.map((asset) { + asset.visibility = visibility; + return asset; + }).toList(); + + await _assetRepository.updateAll(updatedAssets); + } } diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index ec053c078b..41709b714c 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -201,4 +201,16 @@ class AuthService { return null; } + + Future<bool> unlockPinCode(String pinCode) { + return _authApiRepository.unlockPinCode(pinCode); + } + + Future<void> lockPinCode() { + return _authApiRepository.lockPinCode(); + } + + Future<void> setupPinCode(String pinCode) { + return _authApiRepository.setupPinCode(pinCode); + } } diff --git a/mobile/lib/services/local_auth.service.dart b/mobile/lib/services/local_auth.service.dart new file mode 100644 index 0000000000..f797e9065a --- /dev/null +++ b/mobile/lib/services/local_auth.service.dart @@ -0,0 +1,26 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/biometric.interface.dart'; +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; +import 'package:immich_mobile/repositories/biometric.repository.dart'; + +final localAuthServiceProvider = Provider( + (ref) => LocalAuthService( + ref.watch(biometricRepositoryProvider), + ), +); + +class LocalAuthService { + // final _log = Logger("LocalAuthService"); + + final IBiometricRepository _biometricRepository; + + LocalAuthService(this._biometricRepository); + + Future<BiometricStatus> getStatus() { + return _biometricRepository.getStatus(); + } + + Future<bool> authenticate([String? message]) async { + return _biometricRepository.authenticate(message); + } +} diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index efd38f1140..d6c44278c7 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,10 +1,10 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/translation.dart'; import 'package:logging/logging.dart'; final memoryServiceProvider = StateProvider<MemoryService>((ref) { @@ -40,10 +40,7 @@ class MemoryService { .getAllByRemoteId(memory.assets.map((e) => e.id)); final yearsAgo = now.year - memory.data.year; if (dbAssets.isNotEmpty) { - final String title = yearsAgo <= 1 - ? 'memories_year_ago'.tr() - : 'memories_years_ago' - .tr(namedArgs: {'years': yearsAgo.toString()}); + final String title = t('years_ago', {'years': yearsAgo.toString()}); memories.add( Memory( title: title, diff --git a/mobile/lib/services/secure_storage.service.dart b/mobile/lib/services/secure_storage.service.dart new file mode 100644 index 0000000000..77803f29c3 --- /dev/null +++ b/mobile/lib/services/secure_storage.service.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/secure_storage.interface.dart'; +import 'package:immich_mobile/repositories/secure_storage.repository.dart'; + +final secureStorageServiceProvider = Provider( + (ref) => SecureStorageService( + ref.watch(secureStorageRepositoryProvider), + ), +); + +class SecureStorageService { + // final _log = Logger("LocalAuthService"); + + final ISecureStorageRepository _secureStorageRepository; + + SecureStorageService(this._secureStorageRepository); + + Future<void> write(String key, String value) async { + await _secureStorageRepository.write(key, value); + } + + Future<void> delete(String key) async { + await _secureStorageRepository.delete(key); + } + + Future<String?> read(String key) async { + return _secureStorageRepository.read(key); + } +} diff --git a/mobile/lib/services/timeline.service.dart b/mobile/lib/services/timeline.service.dart index 4e91d27a7c..7ecad43ca7 100644 --- a/mobile/lib/services/timeline.service.dart +++ b/mobile/lib/services/timeline.service.dart @@ -105,4 +105,13 @@ class TimelineService { return GroupAssetsBy .values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)]; } + + Stream<RenderList> watchLockedTimelineProvider() async* { + final user = _userService.getMyUser(); + + yield* _timelineRepository.watchLockedTimeline( + user.id, + _getGroupByOption(), + ); + } } diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart index 2a593ffb38..a351b09093 100644 --- a/mobile/lib/theme/theme_data.dart +++ b/mobile/lib/theme/theme_data.dart @@ -42,7 +42,7 @@ ThemeData getThemeData({ titleTextStyle: TextStyle( color: colorScheme.primary, fontFamily: _getFontFamilyFromLocale(locale), - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, fontSize: 18, ), backgroundColor: @@ -54,28 +54,28 @@ ThemeData getThemeData({ ), textTheme: const TextTheme( displayLarge: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, + fontSize: 18, + fontWeight: FontWeight.w600, ), displayMedium: TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), displaySmall: TextStyle( fontSize: 12, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), titleSmall: TextStyle( fontSize: 16.0, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), titleMedium: TextStyle( fontSize: 18.0, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), titleLarge: TextStyle( fontSize: 26.0, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), ), elevatedButtonTheme: ElevatedButtonThemeData( diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 6a09f79ce2..4519c6d803 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -20,7 +20,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 10; +const int targetVersion = 11; Future<void> migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, targetVersion); diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index d054749b1e..7c7d9bab88 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -29,7 +29,11 @@ dynamic upgradeDto(dynamic value, String targetType) { case 'UserResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); - addDefault(value, 'visibility', AssetVisibility.timeline); + } + break; + case 'AssetResponseDto': + if (value is Map) { + addDefault(value, 'visibility', 'timeline'); } break; case 'UserAdminResponseDto': diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index c63d819153..1ae583bedd 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -157,3 +158,29 @@ Future<void> handleEditLocation( ref.read(assetServiceProvider).changeLocation(selection.toList(), location); } + +Future<void> handleSetAssetsVisibility( + WidgetRef ref, + BuildContext context, + AssetVisibilityEnum visibility, + List<Asset> selection, { + ToastGravity toastGravity = ToastGravity.BOTTOM, +}) async { + if (selection.isNotEmpty) { + await ref + .watch(assetProvider.notifier) + .setLockedView(selection, visibility); + + final assetOrAssets = selection.length > 1 ? 'assets' : 'asset'; + final toastMessage = visibility == AssetVisibilityEnum.locked + ? 'Added ${selection.length} $assetOrAssets to locked folder' + : 'Removed ${selection.length} $assetOrAssets from locked folder'; + if (context.mounted) { + ImmichToast.show( + context: context, + msg: toastMessage, + gravity: ToastGravity.BOTTOM, + ); + } + } +} diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart index 7a049fa7fd..892e7e5b8a 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -37,6 +38,7 @@ class ControlBottomAppBar extends HookConsumerWidget { final void Function()? onEditTime; final void Function()? onEditLocation; final void Function()? onRemoveFromAlbum; + final void Function()? onToggleLocked; final bool enabled; final bool unfavorite; @@ -58,6 +60,7 @@ class ControlBottomAppBar extends HookConsumerWidget { this.onEditTime, this.onEditLocation, this.onRemoveFromAlbum, + this.onToggleLocked, this.selectionAssetState = const AssetSelectionState(), this.enabled = true, this.unarchive = false, @@ -77,6 +80,7 @@ class ControlBottomAppBar extends HookConsumerWidget { ref.watch(albumProvider).where((a) => a.shared).toList(); const bottomPadding = 0.20; final scrollController = useDraggableScrollController(); + final isInLockedView = ref.watch(inLockedViewProvider); void minimize() { scrollController.animateTo( @@ -133,11 +137,12 @@ class ControlBottomAppBar extends HookConsumerWidget { label: "share".tr(), onPressed: enabled ? () => onShare(true) : null, ), - ControlBoxButton( - iconData: Icons.link_rounded, - label: "control_bottom_app_bar_share_link".tr(), - onPressed: enabled ? () => onShare(false) : null, - ), + if (!isInLockedView) + ControlBoxButton( + iconData: Icons.link_rounded, + label: "share_link".tr(), + onPressed: enabled ? () => onShare(false) : null, + ), if (hasRemote && onArchive != null) ControlBoxButton( iconData: @@ -153,7 +158,7 @@ class ControlBottomAppBar extends HookConsumerWidget { label: (unfavorite ? "unfavorite" : "favorite").tr(), onPressed: enabled ? onFavorite : null, ), - if (hasLocal && hasRemote && onDelete != null) + if (hasLocal && hasRemote && onDelete != null && !isInLockedView) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 90), child: ControlBoxButton( @@ -166,7 +171,7 @@ class ControlBottomAppBar extends HookConsumerWidget { enabled ? () => showForceDeleteDialog(onDelete!) : null, ), ), - if (hasRemote && onDeleteServer != null) + if (hasRemote && onDeleteServer != null && !isInLockedView) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 85), child: ControlBoxButton( @@ -189,9 +194,23 @@ class ControlBottomAppBar extends HookConsumerWidget { : null, ), ), - if (hasLocal && onDeleteLocal != null) + if (isInLockedView) ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 85), + constraints: const BoxConstraints(maxWidth: 110), + child: ControlBoxButton( + iconData: Icons.delete_forever, + label: "delete_dialog_title".tr(), + onPressed: enabled + ? () => showForceDeleteDialog( + onDeleteServer!, + alertMsg: "delete_dialog_alert_remote", + ) + : null, + ), + ), + if (hasLocal && onDeleteLocal != null && !isInLockedView) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 95), child: ControlBoxButton( iconData: Icons.no_cell_outlined, label: "control_bottom_app_bar_delete_from_local".tr(), @@ -231,6 +250,19 @@ class ControlBottomAppBar extends HookConsumerWidget { onPressed: enabled ? onEditLocation : null, ), ), + if (hasRemote) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: ControlBoxButton( + iconData: isInLockedView + ? Icons.lock_open_rounded + : Icons.lock_outline_rounded, + label: isInLockedView + ? "remove_from_locked_folder".tr() + : "move_to_locked_folder".tr(), + onPressed: enabled ? onToggleLocked : null, + ), + ), if (!selectionAssetState.hasLocal && selectionAssetState.selectedCount > 1 && onStack != null) @@ -269,20 +301,40 @@ class ControlBottomAppBar extends HookConsumerWidget { ]; } + getInitialSize() { + if (isInLockedView) { + return 0.20; + } + if (hasRemote) { + return 0.35; + } + return bottomPadding; + } + + getMaxChildSize() { + if (isInLockedView) { + return 0.20; + } + if (hasRemote) { + return 0.65; + } + return bottomPadding; + } + return DraggableScrollableSheet( controller: scrollController, - initialChildSize: hasRemote ? 0.35 : bottomPadding, + initialChildSize: getInitialSize(), minChildSize: bottomPadding, - maxChildSize: hasRemote ? 0.65 : bottomPadding, + maxChildSize: getMaxChildSize(), snap: true, builder: ( BuildContext context, ScrollController scrollController, ) { return Card( - color: context.colorScheme.surfaceContainerLow, - surfaceTintColor: Colors.transparent, - elevation: 18.0, + color: context.colorScheme.surfaceContainerHigh, + surfaceTintColor: context.colorScheme.surfaceContainerHigh, + elevation: 6.0, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(12), @@ -300,27 +352,27 @@ class ControlBottomAppBar extends HookConsumerWidget { const CustomDraggingHandle(), const SizedBox(height: 12), SizedBox( - height: 100, + height: 120, child: ListView( shrinkWrap: true, scrollDirection: Axis.horizontal, children: renderActionButtons(), ), ), - if (hasRemote) + if (hasRemote && !isInLockedView) ...[ const Divider( indent: 16, endIndent: 16, thickness: 1, ), - if (hasRemote) _AddToAlbumTitleRow( onCreateNewAlbum: enabled ? onCreateNewAlbum : null, ), + ], ], ), ), - if (hasRemote) + if (hasRemote && !isInLockedView) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: AddToAlbumSliverList( @@ -352,12 +404,9 @@ class _AddToAlbumTitleRow extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( + Text( "add_to_album", - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), + style: context.textTheme.titleSmall, ).tr(), TextButton.icon( onPressed: onCreateNewAlbum, diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index ceaee581d2..8cc725ab77 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; @@ -15,6 +16,7 @@ import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/album.service.dart'; @@ -395,6 +397,32 @@ class MultiselectGrid extends HookConsumerWidget { } } + void onToggleLockedVisibility() async { + processing.value = true; + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_locked_error_local'.tr(), + ownerErrorMessage: 'home_page_locked_error_partner'.tr(), + ); + if (remoteAssets.isNotEmpty) { + final isInLockedView = ref.read(inLockedViewProvider); + final visibility = isInLockedView + ? AssetVisibilityEnum.timeline + : AssetVisibilityEnum.locked; + + await handleSetAssetsVisibility( + ref, + context, + visibility, + remoteAssets.toList(), + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + Future<T> Function() wrapLongRunningFun<T>( Future<T> Function() fun, { bool showOverlay = true, @@ -460,6 +488,7 @@ class MultiselectGrid extends HookConsumerWidget { onEditLocation: editEnabled ? onEditLocation : null, unfavorite: unfavorite, unarchive: unarchive, + onToggleLocked: onToggleLockedVisibility, onRemoveFromAlbum: onRemoveFromAlbum != null ? wrapLongRunningFun( () => onRemoveFromAlbum!(selection.value), diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 8bfcdc12ca..1ff8596c43 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -46,6 +47,7 @@ class BottomGalleryBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isInLockedView = ref.watch(inLockedViewProvider); final asset = ref.watch(currentAssetProvider); if (asset == null) { return const SizedBox(); @@ -277,7 +279,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'share'.tr(), ): (_) => shareAsset(), }, - if (asset.isImage) + if (asset.isImage && !isInLockedView) { BottomNavigationBarItem( icon: const Icon(Icons.tune_outlined), @@ -285,7 +287,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'edit'.tr(), ): (_) => handleEdit(), }, - if (isOwner) + if (isOwner && !isInLockedView) { asset.isArchived ? BottomNavigationBarItem( @@ -299,7 +301,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'archive'.tr(), ): (_) => handleArchive(), }, - if (isOwner && asset.stackCount > 0) + if (isOwner && asset.stackCount > 0 && !isInLockedView) { BottomNavigationBarItem( icon: const Icon(Icons.burst_mode_outlined), diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 937d1adf32..64cb1c619f 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; @@ -39,6 +40,7 @@ class TopControlAppBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isInLockedView = ref.watch(inLockedViewProvider); const double iconSize = 22.0; final a = ref.watch(assetWatcher(asset)).value ?? asset; final album = ref.watch(currentAlbumProvider); @@ -178,15 +180,22 @@ class TopControlAppBar extends HookConsumerWidget { shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), - if (isOwner && !isInHomePage && !(isInTrash ?? false)) + if (isOwner && + !isInHomePage && + !(isInTrash ?? false) && + !isInLockedView) buildLocateButton(), if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), - if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) + if (asset.isRemote && + (isOwner || isPartner) && + !asset.isTrashed && + !isInLockedView) buildAddToAlbumButton(), if (asset.isTrashed) buildRestoreButton(), - if (album != null && album.shared) buildActivitiesButton(), + if (album != null && album.shared && !isInLockedView) + buildActivitiesButton(), buildMoreInfoButton(), ], ); diff --git a/mobile/lib/widgets/common/drag_sheet.dart b/mobile/lib/widgets/common/drag_sheet.dart index 45addd0c2e..923050bcc6 100644 --- a/mobile/lib/widgets/common/drag_sheet.dart +++ b/mobile/lib/widgets/common/drag_sheet.dart @@ -35,7 +35,9 @@ class ControlBoxButton extends StatelessWidget { Widget build(BuildContext context) { return MaterialButton( padding: const EdgeInsets.all(10), - shape: const CircleBorder(), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), onPressed: onPressed, onLongPress: onLongPressed, minWidth: 75.0, @@ -47,8 +49,8 @@ class ControlBoxButton extends StatelessWidget { const SizedBox(height: 8), Text( label, - style: const TextStyle(fontSize: 12.0), - maxLines: 2, + style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400), + maxLines: 3, textAlign: TextAlign.center, ), ], diff --git a/mobile/lib/widgets/common/immich_toast.dart b/mobile/lib/widgets/common/immich_toast.dart index 7f3207032b..945568a74c 100644 --- a/mobile/lib/widgets/common/immich_toast.dart +++ b/mobile/lib/widgets/common/immich_toast.dart @@ -40,7 +40,7 @@ class ImmichToast { child: Container( padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5.0), + borderRadius: const BorderRadius.all(Radius.circular(16.0)), color: context.colorScheme.surfaceContainer, border: Border.all( color: context.colorScheme.outline.withValues(alpha: .5), @@ -59,14 +59,23 @@ class ImmichToast { msg, style: TextStyle( color: getColor(toastType, context), - fontWeight: FontWeight.bold, - fontSize: 15, + fontWeight: FontWeight.w600, + fontSize: 14, ), ), ), ], ), ), + positionedToastBuilder: (context, child, gravity) { + return Positioned( + top: gravity == ToastGravity.TOP ? 150 : null, + bottom: gravity == ToastGravity.BOTTOM ? 150 : null, + left: MediaQuery.of(context).size.width / 2 - 150, + right: MediaQuery.of(context).size.width / 2 - 150, + child: child, + ); + }, gravity: gravity, toastDuration: Duration(seconds: durationInSecond), ); diff --git a/mobile/lib/widgets/forms/pin_input.dart b/mobile/lib/widgets/forms/pin_input.dart new file mode 100644 index 0000000000..1588a65c60 --- /dev/null +++ b/mobile/lib/widgets/forms/pin_input.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:pinput/pinput.dart'; + +class PinInput extends StatelessWidget { + final Function(String)? onCompleted; + final Function(String)? onChanged; + final int? length; + final bool? obscureText; + final bool? autoFocus; + final bool? hasError; + final String? label; + final TextEditingController? controller; + + const PinInput({ + super.key, + this.onCompleted, + this.onChanged, + this.length, + this.obscureText, + this.autoFocus, + this.hasError, + this.label, + this.controller, + }); + + @override + Widget build(BuildContext context) { + getPinSize() { + final minimumPadding = 18.0; + final gapWidth = 3.0; + final screenWidth = context.width; + final pinWidth = + (screenWidth - (minimumPadding * 2) - (gapWidth * 5)) / (length ?? 6); + + if (pinWidth > 60) { + return const Size(60, 64); + } + + final pinHeight = pinWidth / (60 / 64); + return Size(pinWidth, pinHeight); + } + + final defaultPinTheme = PinTheme( + width: getPinSize().width, + height: getPinSize().height, + textStyle: TextStyle( + fontSize: 24, + color: context.colorScheme.onSurface, + fontFamily: 'Overpass Mono', + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(19)), + border: Border.all(color: context.colorScheme.surfaceBright), + color: context.colorScheme.surfaceContainerHigh, + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: context.textTheme.displayLarge + ?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), + ), + const SizedBox(height: 4), + ], + Pinput( + controller: controller, + forceErrorState: hasError ?? false, + autofocus: autoFocus ?? false, + obscureText: obscureText ?? false, + obscuringWidget: Icon( + Icons.vpn_key_rounded, + color: context.primaryColor, + size: 20, + ), + separatorBuilder: (index) => const SizedBox( + height: 64, + width: 3, + ), + cursor: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + margin: const EdgeInsets.only(bottom: 9), + width: 18, + height: 2, + color: context.primaryColor, + ), + ], + ), + defaultPinTheme: defaultPinTheme, + focusedPinTheme: defaultPinTheme.copyWith( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(19)), + border: Border.all( + color: context.primaryColor.withValues(alpha: 0.5), + width: 2, + ), + color: context.colorScheme.surfaceContainerHigh, + ), + ), + errorPinTheme: defaultPinTheme.copyWith( + decoration: BoxDecoration( + color: context.colorScheme.error.withAlpha(15), + borderRadius: const BorderRadius.all(Radius.circular(19)), + border: Border.all( + color: context.colorScheme.error.withAlpha(100), + width: 2, + ), + ), + ), + pinputAutovalidateMode: PinputAutovalidateMode.onSubmit, + length: length ?? 6, + onChanged: onChanged, + onCompleted: onCompleted, + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/forms/pin_registration_form.dart b/mobile/lib/widgets/forms/pin_registration_form.dart new file mode 100644 index 0000000000..c3cfd3a864 --- /dev/null +++ b/mobile/lib/widgets/forms/pin_registration_form.dart @@ -0,0 +1,128 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/forms/pin_input.dart'; + +class PinRegistrationForm extends HookConsumerWidget { + final Function() onDone; + + const PinRegistrationForm({ + super.key, + required this.onDone, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasError = useState(false); + final newPinCodeController = useTextEditingController(); + final confirmPinCodeController = useTextEditingController(); + + bool validatePinCode() { + if (confirmPinCodeController.text.length != 6) { + return false; + } + + if (newPinCodeController.text != confirmPinCodeController.text) { + return false; + } + + return true; + } + + createNewPinCode() async { + final isValid = validatePinCode(); + if (!isValid) { + hasError.value = true; + return; + } + + try { + await ref.read(authProvider.notifier).setupPinCode( + newPinCodeController.text, + ); + + onDone(); + } catch (error) { + hasError.value = true; + context.showSnackBar( + SnackBar(content: Text(error.toString())), + ); + } + } + + return Form( + child: Column( + children: [ + Icon( + Icons.pin_outlined, + size: 64, + color: context.primaryColor, + ), + const SizedBox(height: 32), + SizedBox( + width: context.width * 0.7, + child: Text( + 'setup_pin_code'.tr(), + style: context.textTheme.labelLarge!.copyWith( + fontSize: 24, + ), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: context.width * 0.8, + child: Text( + 'new_pin_code_subtitle'.tr(), + style: context.textTheme.bodyLarge!.copyWith( + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 32), + PinInput( + controller: newPinCodeController, + label: 'new_pin_code'.tr(), + length: 6, + autoFocus: true, + hasError: hasError.value, + onChanged: (input) { + if (input.length < 6) { + hasError.value = false; + } + }, + ), + const SizedBox(height: 32), + PinInput( + controller: confirmPinCodeController, + label: 'confirm_new_pin_code'.tr(), + length: 6, + hasError: hasError.value, + onChanged: (input) { + if (input.length < 6) { + hasError.value = false; + } + }, + ), + const SizedBox(height: 48), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: createNewPinCode, + child: Text('create'.tr()), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/forms/pin_verification_form.dart b/mobile/lib/widgets/forms/pin_verification_form.dart new file mode 100644 index 0000000000..f4ebf4272f --- /dev/null +++ b/mobile/lib/widgets/forms/pin_verification_form.dart @@ -0,0 +1,94 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/forms/pin_input.dart'; + +class PinVerificationForm extends HookConsumerWidget { + final Function(String) onSuccess; + final VoidCallback? onError; + final bool? autoFocus; + final String? description; + final IconData? icon; + final IconData? successIcon; + + const PinVerificationForm({ + super.key, + required this.onSuccess, + this.onError, + this.autoFocus, + this.description, + this.icon, + this.successIcon, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasError = useState(false); + final isVerified = useState(false); + + verifyPin(String pinCode) async { + final isUnlocked = + await ref.read(authProvider.notifier).unlockPinCode(pinCode); + + if (isUnlocked) { + isVerified.value = true; + + await Future.delayed(const Duration(seconds: 1)); + onSuccess(pinCode); + } else { + hasError.value = true; + onError?.call(); + } + } + + return Form( + child: Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isVerified.value + ? Icon( + successIcon ?? Icons.lock_open_rounded, + size: 64, + color: Colors.green[300], + ) + : Icon( + icon ?? Icons.lock_outline_rounded, + size: 64, + color: hasError.value + ? context.colorScheme.error + : context.primaryColor, + ), + ), + const SizedBox(height: 36), + SizedBox( + width: context.width * 0.7, + child: Text( + description ?? 'enter_your_pin_code_subtitle'.tr(), + style: context.textTheme.labelLarge!.copyWith( + fontSize: 18, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 18), + PinInput( + obscureText: true, + autoFocus: autoFocus, + hasError: hasError.value, + length: 6, + onChanged: (pinCode) { + if (pinCode.length < 6) { + hasError.value = false; + } + }, + onCompleted: verifyPin, + ), + ], + ), + ); + } +} diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 74af8bd1eb..3d85b779cc 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -133,7 +133,7 @@ class AssetResponseDto { DateTime updatedAt; - AssetResponseDtoVisibilityEnum visibility; + AssetVisibility visibility; @override bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && @@ -318,7 +318,7 @@ class AssetResponseDto { type: AssetTypeEnum.fromJson(json[r'type'])!, unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, - visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!, + visibility: AssetVisibility.fromJson(json[r'visibility'])!, ); } return null; @@ -389,83 +389,3 @@ class AssetResponseDto { }; } - -class AssetResponseDtoVisibilityEnum { - /// Instantiate a new enum with the provided [value]. - const AssetResponseDtoVisibilityEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const archive = AssetResponseDtoVisibilityEnum._(r'archive'); - static const timeline = AssetResponseDtoVisibilityEnum._(r'timeline'); - static const hidden = AssetResponseDtoVisibilityEnum._(r'hidden'); - static const locked = AssetResponseDtoVisibilityEnum._(r'locked'); - - /// List of all possible values in this [enum][AssetResponseDtoVisibilityEnum]. - static const values = <AssetResponseDtoVisibilityEnum>[ - archive, - timeline, - hidden, - locked, - ]; - - static AssetResponseDtoVisibilityEnum? fromJson(dynamic value) => AssetResponseDtoVisibilityEnumTypeTransformer().decode(value); - - static List<AssetResponseDtoVisibilityEnum> listFromJson(dynamic json, {bool growable = false,}) { - final result = <AssetResponseDtoVisibilityEnum>[]; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AssetResponseDtoVisibilityEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [AssetResponseDtoVisibilityEnum] to String, -/// and [decode] dynamic data back to [AssetResponseDtoVisibilityEnum]. -class AssetResponseDtoVisibilityEnumTypeTransformer { - factory AssetResponseDtoVisibilityEnumTypeTransformer() => _instance ??= const AssetResponseDtoVisibilityEnumTypeTransformer._(); - - const AssetResponseDtoVisibilityEnumTypeTransformer._(); - - String encode(AssetResponseDtoVisibilityEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a AssetResponseDtoVisibilityEnum. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - AssetResponseDtoVisibilityEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'archive': return AssetResponseDtoVisibilityEnum.archive; - case r'timeline': return AssetResponseDtoVisibilityEnum.timeline; - case r'hidden': return AssetResponseDtoVisibilityEnum.hidden; - case r'locked': return AssetResponseDtoVisibilityEnum.locked; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [AssetResponseDtoVisibilityEnumTypeTransformer] instance. - static AssetResponseDtoVisibilityEnumTypeTransformer? _instance; -} - - diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7e490edd25..3df4e4e8a9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -621,6 +621,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_svg: dependency: "direct main" description: @@ -976,6 +1024,46 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e" + url: "https://pub.dev" + source: hosted + version: "1.0.49" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2" + url: "https://pub.dev" + source: hosted + version: "1.4.3" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" logging: dependency: "direct main" description: @@ -1264,6 +1352,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pinput: + dependency: "direct main" + description: + name: pinput + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" + url: "https://pub.dev" + source: hosted + version: "5.0.1" platform: dependency: transitive description: @@ -1741,6 +1837,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 08e9661d58..37c9ef7498 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -64,6 +64,9 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.2.10 worker_manager: ^7.2.3 + local_auth: ^2.3.0 + pinput: ^5.0.1 + flutter_secure_storage: ^9.2.4 native_video_player: git: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 45e89a960b..e14b1f1e12 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9366,13 +9366,11 @@ "type": "string" }, "visibility": { - "enum": [ - "archive", - "timeline", - "hidden", - "locked" - ], - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] } }, "required": [ diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index b8ffd70759..8320a6d299 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 9abec7f0a8..542f67d62e 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "typescript": "^5.3.3" } }, @@ -23,9 +23,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", - "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "version": "22.15.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", + "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 3daaa27f78..a5d4a1592b 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "typescript": "^5.3.3" }, "repository": { @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "22.15.0" + "node": "22.15.1" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 366f70b2e6..4931b0c2e1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -329,7 +329,7 @@ export type AssetResponseDto = { "type": AssetTypeEnum; unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; updatedAt: string; - visibility: Visibility; + visibility: AssetVisibility; }; export type AlbumResponseDto = { albumName: string; @@ -3697,12 +3697,6 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } -export enum Visibility { - Archive = "archive", - Timeline = "timeline", - Hidden = "hidden", - Locked = "locked" -} export enum AssetOrder { Asc = "asc", Desc = "desc" diff --git a/server/.nvmrc b/server/.nvmrc index b8ffd70759..8320a6d299 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/server/package-lock.json b/server/package-lock.json index 3f00bb575c..bf70dd5f0a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -92,7 +92,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", @@ -5439,9 +5439,9 @@ } }, "node_modules/@types/node": { - "version": "22.15.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", - "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "version": "22.15.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", + "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" diff --git a/server/package.json b/server/package.json index a9336059ee..b588aa153a 100644 --- a/server/package.json +++ b/server/package.json @@ -117,7 +117,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.15.16", + "@types/node": "^22.15.18", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", @@ -154,7 +154,7 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.15.0" + "node": "22.15.1" }, "overrides": { "sharp": "^0.34.0" diff --git a/server/src/constants.ts b/server/src/constants.ts index 6c0319fcee..8268360d9f 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,9 +1,10 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; import { SemVer } from 'semver'; -import { DatabaseExtension, ExifOrientation } from 'src/enum'; +import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; +export const VECTORCHORD_VERSION_RANGE = '>=0.3 <1'; export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; export const VECTOR_VERSION_RANGE = '>=0.5 <1'; @@ -20,8 +21,22 @@ export const EXTENSION_NAMES: Record<DatabaseExtension, string> = { earthdistance: 'earthdistance', vector: 'pgvector', vectors: 'pgvecto.rs', + vchord: 'VectorChord', } as const; +export const VECTOR_EXTENSIONS = [ + DatabaseExtension.VECTORCHORD, + DatabaseExtension.VECTORS, + DatabaseExtension.VECTOR, +] as const; + +export const VECTOR_INDEX_TABLES = { + [VectorIndex.CLIP]: 'smart_search', + [VectorIndex.FACE]: 'face_search', +} as const; + +export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2; + export const SALT_ROUNDS = 10; export const IWorker = 'IWorker'; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 1af9342e0b..6b34ffcafe 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -116,7 +116,7 @@ export const DummyValue = { DATE: new Date(), TIME_BUCKET: '2024-01-01T00:00:00.000Z', BOOLEAN: true, - VECTOR: '[1, 2, 3]', + VECTOR: JSON.stringify(Array.from({ length: 512 }, () => 0)), }; export const GENERATE_SQL_KEY = 'generate-sql-key'; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 4c1f2571e8..9bbfb450b2 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -44,6 +44,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { isArchived!: boolean; isTrashed!: boolean; isOffline!: boolean; + @ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility' }) visibility!: AssetVisibility; exifInfo?: ExifResponseDto; tags?: TagResponseDto[]; diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 7f0df8abb9..99fd1d2149 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -154,9 +154,9 @@ export class EnvDto { @Optional() DB_USERNAME?: string; - @IsEnum(['pgvector', 'pgvecto.rs']) + @IsEnum(['pgvector', 'pgvecto.rs', 'vectorchord']) @Optional() - DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs'; + DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs' | 'vectorchord'; @IsString() @Optional() diff --git a/server/src/enum.ts b/server/src/enum.ts index e49f1636a0..c9cf34383e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -414,6 +414,7 @@ export enum DatabaseExtension { EARTH_DISTANCE = 'earthdistance', VECTOR = 'vector', VECTORS = 'vectors', + VECTORCHORD = 'vchord', } export enum BootstrapEventPriority { diff --git a/server/src/migrations/1700713871511-UsePgVectors.ts b/server/src/migrations/1700713871511-UsePgVectors.ts index e67c7275a7..4511e1001b 100644 --- a/server/src/migrations/1700713871511-UsePgVectors.ts +++ b/server/src/migrations/1700713871511-UsePgVectors.ts @@ -1,15 +1,13 @@ -import { ConfigRepository } from 'src/repositories/config.repository'; +import { getVectorExtension } from 'src/repositories/database.repository'; import { getCLIPModelInfo } from 'src/utils/misc'; import { MigrationInterface, QueryRunner } from 'typeorm'; -const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; - export class UsePgVectors1700713871511 implements MigrationInterface { name = 'UsePgVectors1700713871511'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`SET search_path TO "$user", public, vectors`); - await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${vectorExtension}`); + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${await getVectorExtension(queryRunner)}`); const faceDimQuery = await queryRunner.query(` SELECT CARDINALITY(embedding::real[]) as dimsize FROM asset_faces diff --git a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts index b5d47bb8cd..43809d6364 100644 --- a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts +++ b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts @@ -1,13 +1,12 @@ -import { ConfigRepository } from 'src/repositories/config.repository'; +import { getVectorExtension } from 'src/repositories/database.repository'; import { vectorIndexQuery } from 'src/utils/database'; import { MigrationInterface, QueryRunner } from 'typeorm'; -const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; - export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface { name = 'AddCLIPEmbeddingIndex1700713994428'; public async up(queryRunner: QueryRunner): Promise<void> { + const vectorExtension = await getVectorExtension(queryRunner); await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })); diff --git a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts index 2b41788fe4..5ee91afbcc 100644 --- a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts +++ b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts @@ -1,13 +1,12 @@ -import { ConfigRepository } from 'src/repositories/config.repository'; +import { getVectorExtension } from 'src/repositories/database.repository'; import { vectorIndexQuery } from 'src/utils/database'; import { MigrationInterface, QueryRunner } from 'typeorm'; -const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; - export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface { name = 'AddFaceEmbeddingIndex1700714033632'; public async up(queryRunner: QueryRunner): Promise<void> { + const vectorExtension = await getVectorExtension(queryRunner); await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' })); diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts index 64849708d2..68e1618775 100644 --- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -1,12 +1,11 @@ import { DatabaseExtension } from 'src/enum'; -import { ConfigRepository } from 'src/repositories/config.repository'; +import { getVectorExtension } from 'src/repositories/database.repository'; import { vectorIndexQuery } from 'src/utils/database'; import { MigrationInterface, QueryRunner } from 'typeorm'; -const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; - export class AddFaceSearchRelation1718486162779 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { + const vectorExtension = await getVectorExtension(queryRunner); if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); } @@ -48,11 +47,11 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512)`); await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })); - await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' })); } public async down(queryRunner: QueryRunner): Promise<void> { + const vectorExtension = await getVectorExtension(queryRunner); if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); } diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 577635a912..2301408ffe 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -8,30 +8,14 @@ select "duplicateId", "stackId", "visibility", - "smart_search"."embedding", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "asset_files"."id", - "asset_files"."path", - "asset_files"."type" - from - "asset_files" - where - "asset_files"."assetId" = "assets"."id" - and "asset_files"."type" = $1 - ) as agg - ) as "files" + "smart_search"."embedding" from "assets" left join "smart_search" on "assets"."id" = "smart_search"."assetId" where - "assets"."id" = $2::uuid + "assets"."id" = $1::uuid limit - $3 + $2 -- AssetJobRepository.getForSidecarWriteJob select @@ -199,18 +183,11 @@ select "assets"."id" from "assets" + inner join "smart_search" on "assets"."id" = "smart_search"."assetId" inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" where "assets"."visibility" != $1 and "assets"."deletedAt" is null - and "job_status"."previewAt" is not null - and not exists ( - select - from - "smart_search" - where - "assetId" = "assets"."id" - ) and "job_status"."duplicatesDetectedAt" is null -- AssetJobRepository.streamForEncodeClip diff --git a/server/src/queries/database.repository.sql b/server/src/queries/database.repository.sql index 8c87a7470f..9dc60ac43f 100644 --- a/server/src/queries/database.repository.sql +++ b/server/src/queries/database.repository.sql @@ -11,11 +11,3 @@ WHERE -- DatabaseRepository.getPostgresVersion SHOW server_version - --- DatabaseRepository.shouldReindex -SELECT - idx_status -FROM - pg_vector_index_stat -WHERE - indexname = $1 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index fefc25ee6a..48854f4872 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -204,6 +204,21 @@ where "person"."ownerId" = $3 and "asset_faces"."deletedAt" is null +-- PersonRepository.refreshFaces +with + "added_embeddings" as ( + insert into + "face_search" ("faceId", "embedding") + values + ($1, $2) + ) +select +from + ( + select + 1 + ) as "dummy" + -- PersonRepository.getFacesByIds select "asset_faces".*, diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index c18fe02418..c100089179 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -64,6 +64,9 @@ limit $15 -- SearchRepository.searchSmart +begin +set + local vchordrq.probes = 1 select "assets".* from @@ -83,8 +86,12 @@ limit $7 offset $8 +commit -- SearchRepository.searchDuplicates +begin +set + local vchordrq.probes = 1 with "cte" as ( select @@ -102,18 +109,22 @@ with and "assets"."id" != $5::uuid and "assets"."stackId" is null order by - smart_search.embedding <=> $6 + "distance" limit - $7 + $6 ) select * from "cte" where - "cte"."distance" <= $8 + "cte"."distance" <= $7 +commit -- SearchRepository.searchFaces +begin +set + local vchordrq.probes = 1 with "cte" as ( select @@ -129,16 +140,17 @@ with "assets"."ownerId" = any ($2::uuid[]) and "assets"."deletedAt" is null order by - face_search.embedding <=> $3 + "distance" limit - $4 + $3 ) select * from "cte" where - "cte"."distance" <= $5 + "cte"."distance" <= $4 +commit -- SearchRepository.searchPlaces select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 132bef6988..b9ce52962c 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -28,16 +28,7 @@ export class AssetJobRepository { .selectFrom('assets') .where('assets.id', '=', asUuid(id)) .leftJoin('smart_search', 'assets.id', 'smart_search.assetId') - .select((eb) => [ - 'id', - 'type', - 'ownerId', - 'duplicateId', - 'stackId', - 'visibility', - 'smart_search.embedding', - withFiles(eb, AssetFileType.PREVIEW), - ]) + .select(['id', 'type', 'ownerId', 'duplicateId', 'stackId', 'visibility', 'smart_search.embedding']) .limit(1) .executeTakeFirst(); } @@ -146,10 +137,17 @@ export class AssetJobRepository { @GenerateSql({ params: [], stream: true }) streamForSearchDuplicates(force?: boolean) { - return this.assetsWithPreviews() - .where((eb) => eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id')))) - .$if(!force, (qb) => qb.where('job_status.duplicatesDetectedAt', 'is', null)) + return this.db + .selectFrom('assets') .select(['assets.id']) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) + .where('assets.deletedAt', 'is', null) + .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') + .$if(!force, (qb) => + qb + .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') + .where('job_status.duplicatesDetectedAt', 'is', null), + ) .stream(); } diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 143892fdd0..238b48bcef 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -89,7 +89,7 @@ describe('getEnv', () => { password: 'postgres', }, skipMigrations: false, - vectorExtension: 'vectors', + vectorExtension: undefined, }); }); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 9b3e406437..9a0a24f70f 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -58,7 +58,7 @@ export interface EnvData { database: { config: DatabaseConnectionParams; skipMigrations: boolean; - vectorExtension: VectorExtension; + vectorExtension?: VectorExtension; }; licensePublicKey: { @@ -196,6 +196,22 @@ const getEnv = (): EnvData => { ssl: dto.DB_SSL_MODE || undefined, }; + let vectorExtension: VectorExtension | undefined; + switch (dto.DB_VECTOR_EXTENSION) { + case 'pgvector': { + vectorExtension = DatabaseExtension.VECTOR; + break; + } + case 'pgvecto.rs': { + vectorExtension = DatabaseExtension.VECTORS; + break; + } + case 'vectorchord': { + vectorExtension = DatabaseExtension.VECTORCHORD; + break; + } + } + return { host: dto.IMMICH_HOST, port: dto.IMMICH_PORT || 2283, @@ -251,7 +267,7 @@ const getEnv = (): EnvData => { database: { config: databaseConnection, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, - vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS, + vectorExtension, }, licensePublicKey: isProd ? productionKeys : stagingKeys, diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index addf6bcff0..67bb1b6ca2 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -5,7 +5,16 @@ import { InjectKysely } from 'nestjs-kysely'; import { readdir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import semver from 'semver'; -import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; +import { + EXTENSION_NAMES, + POSTGRES_VERSION_RANGE, + VECTOR_EXTENSIONS, + VECTOR_INDEX_TABLES, + VECTOR_VERSION_RANGE, + VECTORCHORD_LIST_SLACK_FACTOR, + VECTORCHORD_VERSION_RANGE, + VECTORS_VERSION_RANGE, +} from 'src/constants'; import { DB } from 'src/db'; import { GenerateSql } from 'src/decorators'; import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; @@ -14,11 +23,42 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { vectorIndexQuery } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; -import { DataSource } from 'typeorm'; +import { DataSource, QueryRunner } from 'typeorm'; + +export let cachedVectorExtension: VectorExtension | undefined; +export async function getVectorExtension(runner: Kysely<DB> | QueryRunner): Promise<VectorExtension> { + if (cachedVectorExtension) { + return cachedVectorExtension; + } + + cachedVectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + if (cachedVectorExtension) { + return cachedVectorExtension; + } + + let availableExtensions: { name: VectorExtension }[]; + const query = `SELECT name FROM pg_available_extensions WHERE name IN (${VECTOR_EXTENSIONS.map((ext) => `'${ext}'`).join(', ')})`; + if (runner instanceof Kysely) { + const { rows } = await sql.raw<{ name: VectorExtension }>(query).execute(runner); + availableExtensions = rows; + } else { + availableExtensions = (await runner.query(query)) as { name: VectorExtension }[]; + } + const extensionNames = new Set(availableExtensions.map((row) => row.name)); + cachedVectorExtension = VECTOR_EXTENSIONS.find((ext) => extensionNames.has(ext)); + if (!cachedVectorExtension) { + throw new Error(`No vector extension found. Available extensions: ${VECTOR_EXTENSIONS.join(', ')}`); + } + return cachedVectorExtension; +} + +export const probes: Record<VectorIndex, number> = { + [VectorIndex.CLIP]: 1, + [VectorIndex.FACE]: 1, +}; @Injectable() export class DatabaseRepository { - private vectorExtension: VectorExtension; private readonly asyncLock = new AsyncLock(); constructor( @@ -26,7 +66,6 @@ export class DatabaseRepository { private logger: LoggingRepository, private configRepository: ConfigRepository, ) { - this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(DatabaseRepository.name); } @@ -34,6 +73,10 @@ export class DatabaseRepository { await this.db.destroy(); } + getVectorExtension(): Promise<VectorExtension> { + return getVectorExtension(this.db); + } + @GenerateSql({ params: [DatabaseExtension.VECTORS] }) async getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion> { const { rows } = await sql<ExtensionVersion>` @@ -45,7 +88,20 @@ export class DatabaseRepository { } getExtensionVersionRange(extension: VectorExtension): string { - return extension === DatabaseExtension.VECTORS ? VECTORS_VERSION_RANGE : VECTOR_VERSION_RANGE; + switch (extension) { + case DatabaseExtension.VECTORCHORD: { + return VECTORCHORD_VERSION_RANGE; + } + case DatabaseExtension.VECTORS: { + return VECTORS_VERSION_RANGE; + } + case DatabaseExtension.VECTOR: { + return VECTOR_VERSION_RANGE; + } + default: { + throw new Error(`Unsupported vector extension: '${extension}'`); + } + } } @GenerateSql() @@ -59,7 +115,14 @@ export class DatabaseRepository { } async createExtension(extension: DatabaseExtension): Promise<void> { - await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)}`.execute(this.db); + await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)} CASCADE`.execute(this.db); + if (extension === DatabaseExtension.VECTORCHORD) { + const dbName = sql.table(await this.getDatabaseName()); + await sql`ALTER DATABASE ${dbName} SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db); + await sql`SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db); + await sql`ALTER DATABASE ${dbName} SET vchordrq.probes = 1`.execute(this.db); + await sql`SET vchordrq.probes = 1`.execute(this.db); + } } async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<VectorUpdateResult> { @@ -78,120 +141,201 @@ export class DatabaseRepository { await this.db.transaction().execute(async (tx) => { await this.setSearchPath(tx); - if (isVectors && installedVersion === '0.1.1') { - await this.setExtVersion(tx, DatabaseExtension.VECTORS, '0.1.11'); - } - - const isSchemaUpgrade = semver.satisfies(installedVersion, '0.1.1 || 0.1.11'); - if (isSchemaUpgrade && isVectors) { - await this.updateVectorsSchema(tx); - } - await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx); const diff = semver.diff(installedVersion, targetVersion); - if (isVectors && diff && ['minor', 'major'].includes(diff)) { + if (isVectors && (diff === 'major' || diff === 'minor')) { await sql`SELECT pgvectors_upgrade()`.execute(tx); restartRequired = true; - } else { - await this.reindex(VectorIndex.CLIP); - await this.reindex(VectorIndex.FACE); + } else if (diff) { + await Promise.all([this.reindexVectors(VectorIndex.CLIP), this.reindexVectors(VectorIndex.FACE)]); } }); return { restartRequired }; } - async reindex(index: VectorIndex): Promise<void> { - try { - await sql`REINDEX INDEX ${sql.raw(index)}`.execute(this.db); - } catch (error) { - if (this.vectorExtension !== DatabaseExtension.VECTORS) { - throw error; - } - this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`); + async prewarm(index: VectorIndex): Promise<void> { + const vectorExtension = await getVectorExtension(this.db); + if (vectorExtension !== DatabaseExtension.VECTORCHORD) { + return; + } + this.logger.debug(`Prewarming ${index}`); + await sql`SELECT vchordrq_prewarm(${index})`.execute(this.db); + } - const table = await this.getIndexTable(index); - const dimSize = await this.getDimSize(table); - await this.db.transaction().execute(async (tx) => { - await this.setSearchPath(tx); - await sql`DROP INDEX IF EXISTS ${sql.raw(index)}`.execute(tx); - await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx); - await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute( - tx, - ); - await sql.raw(vectorIndexQuery({ vectorExtension: this.vectorExtension, table, indexName: index })).execute(tx); - }); + async reindexVectorsIfNeeded(names: VectorIndex[]): Promise<void> { + const { rows } = await sql<{ + indexdef: string; + indexname: string; + }>`SELECT indexdef, indexname FROM pg_indexes WHERE indexname = ANY(ARRAY[${sql.join(names)}])`.execute(this.db); + + const vectorExtension = await getVectorExtension(this.db); + + const promises = []; + for (const indexName of names) { + const row = rows.find((index) => index.indexname === indexName); + const table = VECTOR_INDEX_TABLES[indexName]; + if (!row) { + promises.push(this.reindexVectors(indexName)); + continue; + } + + switch (vectorExtension) { + case DatabaseExtension.VECTOR: { + if (!row.indexdef.toLowerCase().includes('using hnsw')) { + promises.push(this.reindexVectors(indexName)); + } + break; + } + case DatabaseExtension.VECTORS: { + if (!row.indexdef.toLowerCase().includes('using vectors')) { + promises.push(this.reindexVectors(indexName)); + } + break; + } + case DatabaseExtension.VECTORCHORD: { + const matches = row.indexdef.match(/(?<=lists = \[)\d+/g); + const lists = matches && matches.length > 0 ? Number(matches[0]) : 1; + promises.push( + this.db + .selectFrom(this.db.dynamic.table(table).as('t')) + .select((eb) => eb.fn.countAll<number>().as('count')) + .executeTakeFirstOrThrow() + .then(({ count }) => { + const targetLists = this.targetListCount(count); + this.logger.log(`targetLists=${targetLists}, current=${lists} for ${indexName} of ${count} rows`); + if ( + !row.indexdef.toLowerCase().includes('using vchordrq') || + // slack factor is to avoid frequent reindexing if the count is borderline + (lists !== targetLists && lists !== this.targetListCount(count * VECTORCHORD_LIST_SLACK_FACTOR)) + ) { + probes[indexName] = this.targetProbeCount(targetLists); + return this.reindexVectors(indexName, { lists: targetLists }); + } else { + probes[indexName] = this.targetProbeCount(lists); + } + }), + ); + break; + } + } + } + + if (promises.length > 0) { + await Promise.all(promises); } } - @GenerateSql({ params: [VectorIndex.CLIP] }) - async shouldReindex(name: VectorIndex): Promise<boolean> { - if (this.vectorExtension !== DatabaseExtension.VECTORS) { - return false; + private async reindexVectors(indexName: VectorIndex, { lists }: { lists?: number } = {}): Promise<void> { + this.logger.log(`Reindexing ${indexName}`); + const table = VECTOR_INDEX_TABLES[indexName]; + const vectorExtension = await getVectorExtension(this.db); + const { rows } = await sql<{ + columnName: string; + }>`SELECT column_name as "columnName" FROM information_schema.columns WHERE table_name = ${table}`.execute(this.db); + if (rows.length === 0) { + this.logger.warn( + `Table ${table} does not exist, skipping reindexing. This is only normal if this is a new Immich instance.`, + ); + return; } - - try { - const { rows } = await sql<{ - idx_status: string; - }>`SELECT idx_status FROM pg_vector_index_stat WHERE indexname = ${name}`.execute(this.db); - return rows[0]?.idx_status === 'UPGRADE'; - } catch (error) { - const message: string = (error as any).message; - if (message.includes('index is not existing')) { - return true; - } else if (message.includes('relation "pg_vector_index_stat" does not exist')) { - return false; + const dimSize = await this.getDimensionSize(table); + await this.db.transaction().execute(async (tx) => { + await sql`DROP INDEX IF EXISTS ${sql.raw(indexName)}`.execute(tx); + if (!rows.some((row) => row.columnName === 'embedding')) { + this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`); + await sql`TRUNCATE TABLE ${sql.raw(table)}`.execute(tx); + await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx); } - throw error; - } + await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx); + const schema = vectorExtension === DatabaseExtension.VECTORS ? 'vectors.' : ''; + await sql` + ALTER TABLE ${sql.raw(table)} + ALTER COLUMN embedding + SET DATA TYPE ${sql.raw(schema)}vector(${sql.raw(String(dimSize))})`.execute(tx); + await sql.raw(vectorIndexQuery({ vectorExtension, table, indexName, lists })).execute(tx); + }); + this.logger.log(`Reindexed ${indexName}`); } private async setSearchPath(tx: Transaction<DB>): Promise<void> { await sql`SET search_path TO "$user", public, vectors`.execute(tx); } - private async setExtVersion(tx: Transaction<DB>, extName: DatabaseExtension, version: string): Promise<void> { - await sql`UPDATE pg_catalog.pg_extension SET extversion = ${version} WHERE extname = ${extName}`.execute(tx); + private async getDatabaseName(): Promise<string> { + const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(this.db); + return rows[0].db; } - private async getIndexTable(index: VectorIndex): Promise<string> { - const { rows } = await sql<{ - relname: string | null; - }>`SELECT relname FROM pg_stat_all_indexes WHERE indexrelname = ${index}`.execute(this.db); - const table = rows[0]?.relname; - if (!table) { - throw new Error(`Could not find table for index ${index}`); - } - return table; - } - - private async updateVectorsSchema(tx: Transaction<DB>): Promise<void> { - const extension = DatabaseExtension.VECTORS; - await sql`CREATE SCHEMA IF NOT EXISTS ${extension}`.execute(tx); - await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = ${extension}`.execute(tx); - await sql`ALTER EXTENSION vectors SET SCHEMA vectors`.execute(tx); - await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = ${extension}`.execute(tx); - } - - private async getDimSize(table: string, column = 'embedding'): Promise<number> { + async getDimensionSize(table: string, column = 'embedding'): Promise<number> { const { rows } = await sql<{ dimsize: number }>` SELECT atttypmod as dimsize FROM pg_attribute f JOIN pg_class c ON c.oid = f.attrelid WHERE c.relkind = 'r'::char AND f.attnum > 0 - AND c.relname = ${table} - AND f.attname = '${column}' + AND c.relname = ${table}::text + AND f.attname = ${column}::text `.execute(this.db); const dimSize = rows[0]?.dimsize; if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { - throw new Error(`Could not retrieve dimension size`); + this.logger.warn(`Could not retrieve dimension size of column '${column}' in table '${table}', assuming 512`); + return 512; } return dimSize; } + async setDimensionSize(dimSize: number): Promise<void> { + if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { + throw new Error(`Invalid CLIP dimension size: ${dimSize}`); + } + + // this is done in two transactions to handle concurrent writes + await this.db.transaction().execute(async (trx) => { + await sql`delete from ${sql.table('smart_search')}`.execute(trx); + await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute(); + await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute( + trx, + ); + }); + + const vectorExtension = await this.getVectorExtension(); + await this.db.transaction().execute(async (trx) => { + await sql`drop index if exists clip_index`.execute(trx); + await trx.schema + .alterTable('smart_search') + .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`))) + .execute(); + await sql + .raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: VectorIndex.CLIP })) + .execute(trx); + await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute(); + }); + probes[VectorIndex.CLIP] = 1; + + await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db); + } + + async deleteAllSearchEmbeddings(): Promise<void> { + await sql`truncate ${sql.table('smart_search')}`.execute(this.db); + } + + private targetListCount(count: number) { + if (count < 128_000) { + return 1; + } else if (count < 2_048_000) { + return 1 << (32 - Math.clz32(count / 1000)); + } else { + return 1 << (33 - Math.clz32(Math.sqrt(count))); + } + } + + private targetProbeCount(lists: number) { + return Math.ceil(lists / 8); + } + async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void> { const { database } = this.configRepository.getEnv(); diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index ad18d7ed67..70a9980201 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -398,6 +398,7 @@ export class PersonRepository { return results.map(({ id }) => id); } + @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] }) async refreshFaces( facesToAdd: (Insertable<AssetFaces> & { assetId: string })[], faceIdsToRemove: string[], diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 4e6b6e0fcf..a7b7027b7b 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -5,9 +5,9 @@ import { randomUUID } from 'node:crypto'; import { DB, Exif } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { ConfigRepository } from 'src/repositories/config.repository'; -import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database'; +import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; +import { probes } from 'src/repositories/database.repository'; +import { anyUuid, asUuid, searchAssetBuilder } from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; @@ -168,10 +168,7 @@ export interface GetCameraMakesOptions { @Injectable() export class SearchRepository { - constructor( - @InjectKysely() private db: Kysely<DB>, - private configRepository: ConfigRepository, - ) {} + constructor(@InjectKysely() private db: Kysely<DB>) {} @GenerateSql({ params: [ @@ -236,19 +233,21 @@ export class SearchRepository { }, ], }) - async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) { + searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) { if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) { throw new Error(`Invalid value for 'size': ${pagination.size}`); } - const items = await searchAssetBuilder(this.db, options) - .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') - .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) - .limit(pagination.size + 1) - .offset((pagination.page - 1) * pagination.size) - .execute(); - - return paginationHelper(items, pagination.size); + return this.db.transaction().execute(async (trx) => { + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx); + const items = await searchAssetBuilder(trx, options) + .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') + .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) + .limit(pagination.size + 1) + .offset((pagination.page - 1) * pagination.size) + .execute(); + return paginationHelper(items, pagination.size); + }); } @GenerateSql({ @@ -263,29 +262,32 @@ export class SearchRepository { ], }) searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) { - return this.db - .with('cte', (qb) => - qb - .selectFrom('assets') - .select([ - 'assets.id as assetId', - 'assets.duplicateId', - sql<number>`smart_search.embedding <=> ${embedding}`.as('distance'), - ]) - .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') - .where('assets.ownerId', '=', anyUuid(userIds)) - .where('assets.deletedAt', 'is', null) - .where('assets.visibility', '!=', AssetVisibility.HIDDEN) - .where('assets.type', '=', type) - .where('assets.id', '!=', asUuid(assetId)) - .where('assets.stackId', 'is', null) - .orderBy(sql`smart_search.embedding <=> ${embedding}`) - .limit(64), - ) - .selectFrom('cte') - .selectAll() - .where('cte.distance', '<=', maxDistance as number) - .execute(); + return this.db.transaction().execute(async (trx) => { + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx); + return await trx + .with('cte', (qb) => + qb + .selectFrom('assets') + .select([ + 'assets.id as assetId', + 'assets.duplicateId', + sql<number>`smart_search.embedding <=> ${embedding}`.as('distance'), + ]) + .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') + .where('assets.ownerId', '=', anyUuid(userIds)) + .where('assets.deletedAt', 'is', null) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) + .where('assets.type', '=', type) + .where('assets.id', '!=', asUuid(assetId)) + .where('assets.stackId', 'is', null) + .orderBy('distance') + .limit(64), + ) + .selectFrom('cte') + .selectAll() + .where('cte.distance', '<=', maxDistance as number) + .execute(); + }); } @GenerateSql({ @@ -303,31 +305,36 @@ export class SearchRepository { throw new Error(`Invalid value for 'numResults': ${numResults}`); } - return this.db - .with('cte', (qb) => - qb - .selectFrom('asset_faces') - .select([ - 'asset_faces.id', - 'asset_faces.personId', - sql<number>`face_search.embedding <=> ${embedding}`.as('distance'), - ]) - .innerJoin('assets', 'assets.id', 'asset_faces.assetId') - .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id') - .leftJoin('person', 'person.id', 'asset_faces.personId') - .where('assets.ownerId', '=', anyUuid(userIds)) - .where('assets.deletedAt', 'is', null) - .$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null)) - .$if(!!minBirthDate, (qb) => - qb.where((eb) => eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)])), - ) - .orderBy(sql`face_search.embedding <=> ${embedding}`) - .limit(numResults), - ) - .selectFrom('cte') - .selectAll() - .where('cte.distance', '<=', maxDistance) - .execute(); + return this.db.transaction().execute(async (trx) => { + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.FACE])}`.execute(trx); + return await trx + .with('cte', (qb) => + qb + .selectFrom('asset_faces') + .select([ + 'asset_faces.id', + 'asset_faces.personId', + sql<number>`face_search.embedding <=> ${embedding}`.as('distance'), + ]) + .innerJoin('assets', 'assets.id', 'asset_faces.assetId') + .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id') + .leftJoin('person', 'person.id', 'asset_faces.personId') + .where('assets.ownerId', '=', anyUuid(userIds)) + .where('assets.deletedAt', 'is', null) + .$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null)) + .$if(!!minBirthDate, (qb) => + qb.where((eb) => + eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), + ), + ) + .orderBy('distance') + .limit(numResults), + ) + .selectFrom('cte') + .selectAll() + .where('cte.distance', '<=', maxDistance) + .execute(); + }); } @GenerateSql({ params: [DummyValue.STRING] }) @@ -416,56 +423,6 @@ export class SearchRepository { .execute(); } - async getDimensionSize(): Promise<number> { - const { rows } = await sql<{ dimsize: number }>` - select atttypmod as dimsize - from pg_attribute f - join pg_class c ON c.oid = f.attrelid - where c.relkind = 'r'::char - and f.attnum > 0 - and c.relname = 'smart_search' - and f.attname = 'embedding' - `.execute(this.db); - - const dimSize = rows[0]['dimsize']; - if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { - throw new Error(`Could not retrieve CLIP dimension size`); - } - return dimSize; - } - - async setDimensionSize(dimSize: number): Promise<void> { - if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { - throw new Error(`Invalid CLIP dimension size: ${dimSize}`); - } - - // this is done in two transactions to handle concurrent writes - await this.db.transaction().execute(async (trx) => { - await sql`delete from ${sql.table('smart_search')}`.execute(trx); - await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute(); - await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute( - trx, - ); - }); - - const vectorExtension = this.configRepository.getEnv().database.vectorExtension; - await this.db.transaction().execute(async (trx) => { - await sql`drop index if exists clip_index`.execute(trx); - await trx.schema - .alterTable('smart_search') - .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`))) - .execute(); - await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(trx); - await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute(); - }); - - await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db); - } - - async deleteAllSearchEmbeddings(): Promise<void> { - await sql`truncate ${sql.table('smart_search')}`.execute(this.db); - } - async getCountries(userIds: string[]): Promise<string[]> { const res = await this.getExifField('country', userIds).execute(); return res.map((row) => row.country!); diff --git a/server/src/schema/migrations/1744910873969-InitialMigration.ts b/server/src/schema/migrations/1744910873969-InitialMigration.ts index ce4a37ae3b..63625a69ad 100644 --- a/server/src/schema/migrations/1744910873969-InitialMigration.ts +++ b/server/src/schema/migrations/1744910873969-InitialMigration.ts @@ -1,10 +1,9 @@ import { Kysely, sql } from 'kysely'; import { DatabaseExtension } from 'src/enum'; -import { ConfigRepository } from 'src/repositories/config.repository'; +import { getVectorExtension } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { vectorIndexQuery } from 'src/utils/database'; -const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; const lastMigrationSql = sql<{ name: string }>`SELECT "name" FROM "migrations" ORDER BY "timestamp" DESC LIMIT 1;`; const tableExists = sql<{ result: string | null }>`select to_regclass('migrations') as "result"`; const logger = LoggingRepository.create(); @@ -25,12 +24,14 @@ export async function up(db: Kysely<any>): Promise<void> { return; } + const vectorExtension = await getVectorExtension(db); + await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "unaccent";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "cube";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "earthdistance";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`.execute(db); - await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)}`.execute(db); + await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)} CASCADE`.execute(db); await sql`CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp()) RETURNS uuid VOLATILE LANGUAGE SQL diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index e0ab4a624d..1c89fa313c 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,5 +1,5 @@ import { EXTENSION_NAMES } from 'src/constants'; -import { DatabaseExtension } from 'src/enum'; +import { DatabaseExtension, VectorIndex } from 'src/enum'; import { DatabaseService } from 'src/services/database.service'; import { VectorExtension } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; @@ -47,8 +47,10 @@ describe(DatabaseService.name, () => { describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[ { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, + { extension: DatabaseExtension.VECTORCHORD, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORCHORD] }, ])('should work with $extensionName', ({ extension, extensionName }) => { beforeEach(() => { + mocks.database.getVectorExtension.mockResolvedValue(extension); mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { @@ -240,41 +242,32 @@ describe(DatabaseService.name, () => { }); it(`should reindex ${extension} indices if needed`, async () => { - mocks.database.shouldReindex.mockResolvedValue(true); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); - expect(mocks.database.reindex).toHaveBeenCalledTimes(2); + expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([ + VectorIndex.CLIP, + VectorIndex.FACE, + ]); + expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledTimes(1); expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw an error if reindexing fails`, async () => { - mocks.database.shouldReindex.mockResolvedValue(true); - mocks.database.reindex.mockRejectedValue(new Error('Error reindexing')); + mocks.database.reindexVectorsIfNeeded.mockRejectedValue(new Error('Error reindexing')); await expect(sut.onBootstrap()).rejects.toBeDefined(); - expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1); - expect(mocks.database.reindex).toHaveBeenCalledTimes(1); + expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([ + VectorIndex.CLIP, + VectorIndex.FACE, + ]); expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(mocks.logger.fatal).not.toHaveBeenCalled(); expect(mocks.logger.warn).toHaveBeenCalledWith( expect.stringContaining('Could not run vector reindexing checks.'), ); }); - - it(`should not reindex ${extension} indices if not needed`, async () => { - mocks.database.shouldReindex.mockResolvedValue(false); - - await expect(sut.onBootstrap()).resolves.toBeUndefined(); - - expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); - expect(mocks.database.reindex).toHaveBeenCalledTimes(0); - expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); - expect(mocks.logger.fatal).not.toHaveBeenCalled(); - }); }); it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { @@ -300,23 +293,7 @@ describe(DatabaseService.name, () => { expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); - it(`should throw error if pgvector extension could not be created`, async () => { - mocks.config.getEnv.mockReturnValue( - mockEnvData({ - database: { - config: { - connectionType: 'parts', - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - database: 'immich', - }, - skipMigrations: true, - vectorExtension: DatabaseExtension.VECTOR, - }, - }), - ); + it(`should throw error if extension could not be created`, async () => { mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, @@ -328,26 +305,7 @@ describe(DatabaseService.name, () => { expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); expect(mocks.logger.fatal.mock.calls[0][0]).toContain( - `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, - ); - expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); - expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); - expect(mocks.database.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw error if pgvecto.rs extension could not be created`, async () => { - mocks.database.getExtensionVersion.mockResolvedValue({ - installedVersion: null, - availableVersion: minVersionInRange, - }); - mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); - - await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - - expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); - expect(mocks.logger.fatal.mock.calls[0][0]).toContain( - `Alternatively, if your Postgres instance has pgvector, you may use this instead`, + `Alternatively, if your Postgres instance has any of vector, vectors, vchord, you may use one of them instead by setting the environment variable 'DB_VECTOR_EXTENSION=<extension name>'`, ); expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index d71dc25104..ec7be195ba 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -6,7 +6,7 @@ import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex } import { BaseService } from 'src/services/base.service'; import { VectorExtension } from 'src/types'; -type CreateFailedArgs = { name: string; extension: string; otherName: string }; +type CreateFailedArgs = { name: string; extension: string; otherExtensions: string[] }; type UpdateFailedArgs = { name: string; extension: string; availableVersion: string }; type RestartRequiredArgs = { name: string; availableVersion: string }; type NightlyVersionArgs = { name: string; extension: string; version: string }; @@ -25,18 +25,15 @@ const messages = { outOfRange: ({ name, version, range }: OutOfRangeArgs) => `The ${name} extension version is ${version}, but Immich only supports ${range}. Please change ${name} to a compatible version in the Postgres instance.`, - createFailed: ({ name, extension, otherName }: CreateFailedArgs) => + createFailed: ({ name, extension, otherExtensions }: CreateFailedArgs) => `Failed to activate ${name} extension. Please ensure the Postgres instance has ${name} installed. If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it. - In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension}' manually as a superuser. + In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension} CASCADE' manually as a superuser. See https://immich.app/docs/guides/database-queries for how to query the database. - Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'. - Note that switching between the two extensions after a successful startup is not supported. - The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier. - In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.`, + Alternatively, if your Postgres instance has any of ${otherExtensions.join(', ')}, you may use one of them instead by setting the environment variable 'DB_VECTOR_EXTENSION=<extension name>'.`, updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) => `The ${name} extension can be updated to ${availableVersion}. Immich attempted to update the extension, but failed to do so. @@ -67,8 +64,7 @@ export class DatabaseService extends BaseService { } await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => { - const envData = this.configRepository.getEnv(); - const extension = envData.database.vectorExtension; + const extension = await this.databaseRepository.getVectorExtension(); const name = EXTENSION_NAMES[extension]; const extensionRange = this.databaseRepository.getExtensionVersionRange(extension); @@ -97,12 +93,23 @@ export class DatabaseService extends BaseService { throw new Error(messages.invalidDowngrade({ name, extension, availableVersion, installedVersion })); } - await this.checkReindexing(); + try { + await this.databaseRepository.reindexVectorsIfNeeded([VectorIndex.CLIP, VectorIndex.FACE]); + } catch (error) { + this.logger.warn( + 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance. If you are upgrading directly from a version below 1.107.2, please upgrade to 1.107.2 first.', + ); + throw error; + } const { database } = this.configRepository.getEnv(); if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); } + await Promise.all([ + this.databaseRepository.prewarm(VectorIndex.CLIP), + this.databaseRepository.prewarm(VectorIndex.FACE), + ]); }); } @@ -110,10 +117,13 @@ export class DatabaseService extends BaseService { try { await this.databaseRepository.createExtension(extension); } catch (error) { - const otherExtension = - extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; + const otherExtensions = [ + DatabaseExtension.VECTOR, + DatabaseExtension.VECTORS, + DatabaseExtension.VECTORCHORD, + ].filter((ext) => ext !== extension); const name = EXTENSION_NAMES[extension]; - this.logger.fatal(messages.createFailed({ name, extension, otherName: EXTENSION_NAMES[otherExtension] })); + this.logger.fatal(messages.createFailed({ name, extension, otherExtensions })); throw error; } } @@ -130,21 +140,4 @@ export class DatabaseService extends BaseService { throw error; } } - - private async checkReindexing() { - try { - if (await this.databaseRepository.shouldReindex(VectorIndex.CLIP)) { - await this.databaseRepository.reindex(VectorIndex.CLIP); - } - - if (await this.databaseRepository.shouldReindex(VectorIndex.FACE)) { - await this.databaseRepository.reindex(VectorIndex.FACE); - } - } catch (error) { - this.logger.warn( - 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance.', - ); - throw error; - } - } } diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 3f08e36a21..d23144babe 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,4 +1,4 @@ -import { AssetFileType, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; +import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -11,17 +11,6 @@ vitest.useFakeTimers(); const hasEmbedding = { id: 'asset-1', ownerId: 'user-id', - files: [ - { - assetId: 'asset-1', - createdAt: new Date(), - id: 'file-1', - path: 'preview.jpg', - type: AssetFileType.PREVIEW, - updatedAt: new Date(), - updateId: 'update-1', - }, - ], stackId: null, type: AssetType.IMAGE, duplicateId: null, @@ -218,15 +207,6 @@ describe(SearchService.name, () => { expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); }); - it('should fail if asset is missing preview image', async () => { - mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, files: [] }); - - const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); - - expect(result).toBe(JobStatus.FAILED); - expect(mocks.logger.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); - }); - it('should fail if asset is missing embedding', async () => { mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 3e19afe454..c5b628d9cb 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -4,11 +4,10 @@ import { OnJob } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateInfoResponseDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto'; -import { AssetFileType, AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum'; +import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; -import { getAssetFile } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; @Injectable() @@ -86,17 +85,11 @@ export class DuplicateService extends BaseService { return JobStatus.SKIPPED; } - if (asset.visibility == AssetVisibility.HIDDEN) { + if (asset.visibility === AssetVisibility.HIDDEN) { this.logger.debug(`Asset ${id} is not visible, skipping`); return JobStatus.SKIPPED; } - const previewFile = getAssetFile(asset.files || [], AssetFileType.PREVIEW); - if (!previewFile) { - this.logger.warn(`Asset ${id} is missing preview image`); - return JobStatus.FAILED; - } - if (!asset.embedding) { this.logger.debug(`Asset ${id} is missing embedding`); return JobStatus.FAILED; diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 23ba562ba6..cd484c230b 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -33,6 +33,7 @@ import { QueueName, SourceType, SystemMetadataKey, + VectorIndex, } from 'src/enum'; import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { UpdateFacesData } from 'src/repositories/person.repository'; @@ -418,6 +419,8 @@ export class PersonService extends BaseService { return JobStatus.SKIPPED; } + await this.databaseRepository.prewarm(VectorIndex.FACE); + const lastRun = new Date().toISOString(); const facePagination = this.personRepository.getAllFaces( force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING }, diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 9cc97a8f0d..a6529fa623 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -54,28 +54,28 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig }); - expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); - expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.database.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.database.getDimensionSize.mockResolvedValue(512); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); - expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - mocks.search.getDimensionSize.mockResolvedValue(768); + mocks.database.getDimensionSize.mockResolvedValue(768); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); - expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); + expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(512); }); }); @@ -89,13 +89,13 @@ describe(SmartInfoService.name, () => { }); expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); - expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); - expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.database.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.database.getDimensionSize.mockResolvedValue(512); await sut.onConfigUpdate({ newConfig: { @@ -106,13 +106,13 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); - expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.database.getDimensionSize.mockResolvedValue(512); await sut.onConfigUpdate({ newConfig: { @@ -123,12 +123,12 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); - expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768); + expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(768); }); it('should clear embeddings if old and new models are different', async () => { - mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.database.getDimensionSize.mockResolvedValue(512); await sut.onConfigUpdate({ newConfig: { @@ -139,9 +139,9 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); - expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); }); }); @@ -151,7 +151,7 @@ describe(SmartInfoService.name, () => { await sut.handleQueueEncodeClip({}); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); }); it('should queue the assets without clip embeddings', async () => { @@ -163,7 +163,7 @@ describe(SmartInfoService.name, () => { { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, ]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); }); it('should queue all the assets', async () => { @@ -175,7 +175,7 @@ describe(SmartInfoService.name, () => { { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, ]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true); - expect(mocks.search.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512); + expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512); }); }); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index f3702c2010..705e8ed2e5 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -38,7 +38,7 @@ export class SmartInfoService extends BaseService { await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => { const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName); - const dbDimSize = await this.searchRepository.getDimensionSize(); + const dbDimSize = await this.databaseRepository.getDimensionSize('smart_search'); this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`); const modelChange = @@ -53,10 +53,10 @@ export class SmartInfoService extends BaseService { `Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`, ); this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`); - await this.searchRepository.setDimensionSize(dimSize); + await this.databaseRepository.setDimensionSize(dimSize); this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`); } else { - await this.searchRepository.deleteAllSearchEmbeddings(); + await this.databaseRepository.deleteAllSearchEmbeddings(); } // TODO: A job to reindex all assets should be scheduled, though user @@ -74,7 +74,7 @@ export class SmartInfoService extends BaseService { if (force) { const { dimSize } = getCLIPModelInfo(machineLearning.clip.modelName); // in addition to deleting embeddings, update the dimension size in case it failed earlier - await this.searchRepository.setDimensionSize(dimSize); + await this.databaseRepository.setDimensionSize(dimSize); } let queue: JobItem[] = []; diff --git a/server/src/types.ts b/server/src/types.ts index 52a5266e42..9479a39eea 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,7 +1,7 @@ import { SystemConfig } from 'src/config'; +import { VECTOR_EXTENSIONS } from 'src/constants'; import { AssetType, - DatabaseExtension, DatabaseSslMode, ExifOrientation, ImageFormat, @@ -363,7 +363,7 @@ export type JobItem = // Version check | { name: JobName.VERSION_CHECK; data: IBaseJob }; -export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; +export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; export type DatabaseConnectionURL = { connectionType: 'url'; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index e0e7af49a4..40bf7503db 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -384,14 +384,28 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)); } -type VectorIndexOptions = { vectorExtension: VectorExtension; table: string; indexName: string }; +export type ReindexVectorIndexOptions = { indexName: string; lists?: number }; -export function vectorIndexQuery({ vectorExtension, table, indexName }: VectorIndexOptions): string { +type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions; + +export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: VectorIndexQueryOptions): string { switch (vectorExtension) { + case DatabaseExtension.VECTORCHORD: { + return ` + CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$ + residual_quantization = false + [build.internal] + lists = [${lists ?? 1}] + spherical_centroids = true + build_threads = 4 + sampling_factor = 1024 + $$)`; + } case DatabaseExtension.VECTORS: { return ` CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vectors (embedding vector_cos_ops) WITH (options = $$ + optimizing.optimizing_threads = 4 [indexing.hnsw] m = 16 ef_construction = 300 diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 6f4f46c075..8b730c2b41 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -170,7 +170,7 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys } case 'search': { - return new SearchRepository(db, new ConfigRepository()); + return new SearchRepository(db); } case 'session': { diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts index 4398da5c0a..323d2c4a53 100644 --- a/server/test/medium/globalSetup.ts +++ b/server/test/medium/globalSetup.ts @@ -7,7 +7,7 @@ import { getKyselyConfig } from 'src/utils/database'; import { GenericContainer, Wait } from 'testcontainers'; const globalSetup = async () => { - const postgresContainer = await new GenericContainer('tensorchord/pgvecto-rs:pg14-v0.2.0') + const postgresContainer = await new GenericContainer('ghcr.io/immich-app/postgres:14') .withExposedPorts(5432) .withEnvironment({ POSTGRES_PASSWORD: 'postgres', @@ -17,9 +17,7 @@ const globalSetup = async () => { .withCommand([ 'postgres', '-c', - 'shared_preload_libraries=vectors.so', - '-c', - 'search_path="$$user", public, vectors', + 'shared_preload_libraries=vchord.so', '-c', 'max_wal_size=2GB', '-c', @@ -30,6 +28,8 @@ const globalSetup = async () => { 'full_page_writes=off', '-c', 'synchronous_commit=off', + '-c', + 'config_file=/var/lib/postgresql/data/postgresql.conf', ]) .withWaitStrategy(Wait.forAll([Wait.forLogMessage('database system is ready to accept connections', 2)])) .start(); diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index eeedf682de..171e04fe33 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -6,13 +6,17 @@ export const newDatabaseRepositoryMock = (): Mocked<RepositoryInterface<Database return { shutdown: vitest.fn(), getExtensionVersion: vitest.fn(), + getVectorExtension: vitest.fn(), getExtensionVersionRange: vitest.fn(), getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), getPostgresVersionRange: vitest.fn().mockReturnValue('>=14.0.0'), createExtension: vitest.fn().mockResolvedValue(void 0), updateVectorExtension: vitest.fn(), - reindex: vitest.fn(), - shouldReindex: vitest.fn(), + reindexVectorsIfNeeded: vitest.fn(), + getDimensionSize: vitest.fn(), + setDimensionSize: vitest.fn(), + deleteAllSearchEmbeddings: vitest.fn(), + prewarm: vitest.fn(), runMigrations: vitest.fn(), withLock: vitest.fn().mockImplementation((_, function_: <R>() => Promise<R>) => function_()), tryLock: vitest.fn(), diff --git a/web/.nvmrc b/web/.nvmrc index b8ffd70759..8320a6d299 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/web/package.json b/web/package.json index 99df56b7f0..e61c3919ee 100644 --- a/web/package.json +++ b/web/package.json @@ -100,6 +100,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.15.0" + "node": "22.15.1" } } diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte index 91db84b172..dff470f456 100644 --- a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte @@ -6,7 +6,7 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { handleError } from '$lib/utils/handle-error'; import { AssetVisibility, updateAssets } from '@immich/sdk'; - import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js'; + import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { OnAction, PreAction } from './action'; @@ -57,5 +57,5 @@ <MenuOption onClick={() => toggleLockedVisibility()} text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')} - icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline} + icon={isLocked ? mdiLockOpenVariantOutline : mdiLockOutline} /> diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 70600e6208..19705f05b6 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -29,7 +29,7 @@ import { AssetJobName, AssetTypeEnum, - Visibility, + AssetVisibility, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, @@ -94,7 +94,7 @@ const sharedLink = getSharedLink(); let isOwner = $derived($user && asset.ownerId === $user?.id); let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); - let isLocked = $derived(asset.visibility === Visibility.Locked); + let isLocked = $derived(asset.visibility === AssetVisibility.Locked); // $: showEditorButton = // isOwner && diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 564cef5308..b00be3c2f3 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -216,7 +216,7 @@ <img src={assetFileUrl} alt="" - class="absolute top-0 start-0 object-cover h-full w-full blur-lg" + class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg" draggable="false" /> {/if} diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte index 327227f0b4..949f069caf 100644 --- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte +++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte @@ -1,10 +1,10 @@ <script lang="ts"> import { shortcuts } from '$lib/actions/shortcut'; - import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte'; import SlideshowSettings from '$lib/components/slideshow-settings.svelte'; import { ProgressBarStatus } from '$lib/constants'; import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store'; + import { IconButton } from '@immich/ui'; import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { swipe } from 'svelte-gestures'; @@ -98,6 +98,13 @@ } onNext(); }; + + const onSettingToggled = async () => { + showSettings = !showSettings; + if (document.fullscreenElement && showSettings) { + await document.exitFullscreen(); + } + }; </script> <svelte:window @@ -119,28 +126,61 @@ transition:fly={{ duration: 150 }} role="navigation" > - <CircleIconButton buttonSize="50" icon={mdiClose} onclick={onClose} title={$t('exit_slideshow')} /> + <IconButton + variant="ghost" + shape="round" + color="secondary" + icon={mdiClose} + onclick={onClose} + aria-label={$t('exit_slideshow')} + class="text-white" + /> - <CircleIconButton - buttonSize="50" + <IconButton + variant="ghost" + shape="round" + color="secondary" icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause} onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())} - title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')} + aria-label={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')} + class="text-white" /> - <CircleIconButton buttonSize="50" icon={mdiChevronLeft} onclick={onPrevious} title={$t('previous')} /> - <CircleIconButton buttonSize="50" icon={mdiChevronRight} onclick={onNext} title={$t('next')} /> - <CircleIconButton - buttonSize="50" + <IconButton + variant="ghost" + shape="round" + color="secondary" + icon={mdiChevronLeft} + onclick={onPrevious} + aria-label={$t('previous')} + class="text-white" + /> + <IconButton + variant="ghost" + shape="round" + color="secondary" + icon={mdiChevronRight} + onclick={onNext} + aria-label={$t('next')} + class="text-white" + /> + <IconButton + variant="ghost" + shape="round" + color="secondary" icon={mdiCog} - onclick={() => (showSettings = !showSettings)} - title={$t('slideshow_settings')} + onclick={onSettingToggled} + aria-label={$t('slideshow_settings')} + class="text-white" /> {#if !isFullScreen} - <CircleIconButton - buttonSize="50" + <IconButton + variant="ghost" + shape="round" + color="secondary" icon={mdiFullscreen} onclick={onSetToFullScreen} - title={$t('set_slideshow_to_fullscreen')} + aria-label={$t('set_slideshow_to_fullscreen')} + class="text-white" /> {/if} </div> diff --git a/web/src/lib/components/photos-page/actions/set-visibility-action.svelte b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte index c11ba114ce..407a92fadc 100644 --- a/web/src/lib/components/photos-page/actions/set-visibility-action.svelte +++ b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte @@ -7,7 +7,7 @@ import { handleError } from '$lib/utils/handle-error'; import { AssetVisibility, updateAssets } from '@immich/sdk'; import { Button } from '@immich/ui'; - import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js'; + import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { @@ -56,11 +56,11 @@ <MenuOption onClick={setLockedVisibility} text={unlock ? $t('move_off_locked_folder') : $t('add_to_locked_folder')} - icon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline} + icon={unlock ? mdiLockOpenVariantOutline : mdiLockOutline} /> {:else} <Button - leadingIcon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline} + leadingIcon={unlock ? mdiLockOpenVariantOutline : mdiLockOutline} disabled={loading} size="medium" color="secondary" diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 56e586456d..13f1c72da1 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -118,7 +118,7 @@ > {#snippet promptSnippet()} <div class="flex flex-col w-full h-full gap-2"> - <div class="relative w-64 sm:w-96"> + <div class="relative w-64 sm:w-96 z-1"> {#if suggestionContainer} <div use:listNavigation={suggestionContainer}> <button type="button" class="w-full" onclick={() => (hideSuggestion = false)}> @@ -160,7 +160,7 @@ </div> <span>{$t('pick_a_location')}</span> - <div class="h-[500px] min-h-[300px] w-full"> + <div class="h-[500px] min-h-[300px] w-full z-0"> {#await import('../shared-components/map/map.svelte')} {#await delay(timeToLoadTheMap) then} <!-- show the loading spinner only if loading the map takes too much time --> diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte index f76f187ad9..aafa8377fd 100644 --- a/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte @@ -39,7 +39,7 @@ in:fade={{ duration: 100 }} out:fade={{ duration: 100 }} id="notification-panel" - class="absolute right-[25px] top-[70px] z-1 w-[min(360px,100vw-50px)] rounded-3xl bg-gray-100 border border-gray-200 shadow-lg dark:border dark:border-light dark:bg-immich-dark-gray text-light px-2" + class="absolute right-[25px] top-[70px] z-1 w-[min(360px,100vw-50px)] rounded-3xl bg-gray-100 border border-gray-200 shadow-lg dark:border dark:border-light dark:bg-immich-dark-gray text-light" use:focusTrap > <Stack class="max-h-[500px]"> @@ -57,7 +57,7 @@ </div> </div> - <hr /> + <hr class="dark:border-black" /> {#if noUnreadNotifications} <Stack diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 66436940d5..fca68a6aec 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -2,7 +2,7 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { locale } from '$lib/stores/preferences.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; -import { AssetTypeEnum, AssetVisibility, type AssetResponseDto } from '@immich/sdk'; +import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { memoize } from 'lodash-es'; import { DateTime, type LocaleOptions } from 'luxon'; @@ -72,6 +72,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): const city = assetResponse.exifInfo?.city; const country = assetResponse.exifInfo?.country; const people = assetResponse.people?.map((person) => person.name) || []; + return { id: assetResponse.id, ownerId: assetResponse.ownerId, @@ -79,7 +80,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): thumbhash: assetResponse.thumbhash, localDateTime: assetResponse.localDateTime, isFavorite: assetResponse.isFavorite, - visibility: assetResponse.isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline, + visibility: assetResponse.visibility, isTrashed: assetResponse.isTrashed, isVideo: assetResponse.type == AssetTypeEnum.Video, isImage: assetResponse.type == AssetTypeEnum.Image, diff --git a/web/src/routes/auth/pin-prompt/+page.svelte b/web/src/routes/auth/pin-prompt/+page.svelte index 6c541aaef0..f3edc87e33 100644 --- a/web/src/routes/auth/pin-prompt/+page.svelte +++ b/web/src/routes/auth/pin-prompt/+page.svelte @@ -65,7 +65,7 @@ onFilled={handleUnlockSession} /> - <Button type="button" color="secondary" onclick={() => goto(AppRoute.PHOTOS)}>Back</Button> + <Button type="button" color="secondary" onclick={() => goto(AppRoute.PHOTOS)}>{$t('cancel')}</Button> </div> </div> {:else} diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index e36bec6c4e..f68c3a1a1a 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -1,12 +1,6 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { faker } from '@faker-js/faker'; -import { - AssetTypeEnum, - AssetVisibility, - Visibility, - type AssetResponseDto, - type TimeBucketAssetResponseDto, -} from '@immich/sdk'; +import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; export const assetFactory = Sync.makeFactory<AssetResponseDto>({ @@ -31,7 +25,7 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({ checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), - visibility: Visibility.Timeline, + visibility: AssetVisibility.Timeline, }); export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({