diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7584eb8075..c6c2b3b51e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,7 +26,81 @@ "vitest.explorer", "ms-playwright.playwright", "ms-azuretools.vscode-docker" - ] + ], + "settings": { + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "Fix Permissions, Install Dependencies", + "type": "shell", + "command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0", + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false, + "group": "Devcontainer tasks", + "close": true + }, + "runOptions": { + "runOn": "default" + }, + "problemMatcher": [] + }, + { + "label": "Immich API Server (Nest)", + "dependsOn": ["Fix Permissions, Install Dependencies"], + "type": "shell", + "command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0", + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false, + "group": "Devcontainer tasks", + "close": true + }, + "runOptions": { + "runOn": "folderOpen" + }, + "problemMatcher": [] + }, + { + "label": "Immich Web Server (Vite)", + "dependsOn": ["Fix Permissions, Install Dependencies"], + "type": "shell", + "command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0", + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false, + "group": "Devcontainer tasks", + "close": true + }, + "runOptions": { + "runOn": "folderOpen" + }, + "problemMatcher": [] + }, + { + "label": "Build Immich CLI", + "type": "shell", + "command": "pnpm --filter cli build:dev" + } + ] + } + } } }, "features": { diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d80849f7e..28a74ff33f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -591,9 +591,9 @@ jobs: - name: Lint with ruff run: | uv run ruff check --output-format=github immich_ml - - name: Check black formatting + - name: Format with ruff run: | - uv run black --check immich_ml + uv run ruff format --check immich_ml - name: Run mypy type checking run: | uv run mypy --strict immich_ml/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 478a46b4bd..0000000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Fix Permissions, Install Dependencies", - "type": "shell", - "command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0", - "isBackground": true, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": true, - "clear": false, - "group": "Devcontainer tasks", - "close": true - }, - "runOptions": { - "runOn": "default" - }, - "problemMatcher": [] - }, - { - "label": "Immich API Server (Nest)", - "dependsOn": ["Fix Permissions, Install Dependencies"], - "type": "shell", - "command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0", - "isBackground": true, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": true, - "clear": false, - "group": "Devcontainer tasks", - "close": true - }, - "runOptions": { - "runOn": "default" - }, - "problemMatcher": [] - }, - { - "label": "Immich Web Server (Vite)", - "dependsOn": ["Fix Permissions, Install Dependencies"], - "type": "shell", - "command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0", - "isBackground": true, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": true, - "clear": false, - "group": "Devcontainer tasks", - "close": true - }, - "runOptions": { - "runOn": "default" - }, - "problemMatcher": [] - }, - { - "label": "Immich Server and Web", - "dependsOn": ["Immich Web Server (Vite)", "Immich API Server (Nest)"], - "runOptions": { - "runOn": "folderOpen" - }, - "problemMatcher": [] - }, - { - "label": "Build Immich CLI", - "type": "shell", - "command": "pnpm --filter cli build:dev" - } - ] -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7199043658..109708cc6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,9 +23,21 @@ We generally discourage PRs entirely generated by an LLM. For any part generated From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on: -* Sharing/Asset ownership -* (External) libraries +- Sharing/Asset ownership +- (External) libraries ## Non-code contributions -If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated. +If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. + +### Translations + +All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! + +### Datasets + +Help us improve our [Immich Datasets](https://datasets.immich.app) by submitting photos and videos taken from a variety of devices, including smartphones, DSLRs, and action cameras, as well as photos with unique features, such as panoramas, burst photos, and photo spheres. These datasets will be publically available for anyone to use, do not submit private/sensitive photos. + +### Community support + +If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated. diff --git a/cli/package.json b/cli/package.json index 23a2ec062d..3d3bae1914 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.5.2", + "version": "2.5.5", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/deployment/mise.toml b/deployment/mise.toml index 73fb6dd47f..d77ec84125 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,6 +1,6 @@ [tools] terragrunt = "0.98.0" -opentofu = "1.10.7" +opentofu = "1.11.4" [tasks."tg:fmt"] run = "terragrunt hclfmt" diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index e250f5065b..217ec08030 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -97,7 +97,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.3.1-ubuntu@sha256:d57f1365197aec34c4d80869d8ca45bb7787c7663904950dab214dfb40c1c2fd + image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b volumes: - grafana-data:/var/lib/grafana diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 111a39ac8d..ae605f8462 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -140,7 +140,8 @@ For advanced users or automated recovery scenarios, you can restore a database b ```bash title='Backup' # Replace with the database username - usually postgres unless you have changed it. -docker exec -t immich_postgres pg_dumpall --clean --if-exists --username= | gzip > "/path/to/backup/dump.sql.gz" +# Replace with the database name - usually immich unless you have changed it. +docker exec -t immich_postgres pg_dump --clean --if-exists --dbname= --username= | gzip > "/path/to/backup/dump.sql.gz" ``` ```bash title='Restore' @@ -153,9 +154,10 @@ docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up # Check the database user if you deviated from the default # Replace with the database username - usually postgres unless you have changed it. +# Replace with the database name - usually immich unless you have changed it. gunzip --stdout "/path/to/backup/dump.sql.gz" \ | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \ -| docker exec -i immich_postgres psql --dbname=postgres --username= # Restore Backup +| docker exec -i immich_postgres psql --dbname= --username= --single-transaction --set ON_ERROR_STOP=on # Restore Backup docker compose up -d # Start remainder of Immich apps ``` @@ -164,7 +166,8 @@ docker compose up -d # Start remainder of Immich apps ```powershell title='Backup' # Replace with the database username - usually postgres unless you have changed it. -[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=)) +# Replace with the database name - usually immich unless you have changed it. +[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dump --clean --if-exists --dbname= --username=)) ``` ```powershell title='Restore' @@ -179,8 +182,9 @@ sleep 10 # Wait for Postgres server to docker exec -it immich_postgres bash # Enter the Docker shell and run the following command # If your backup ends in `.gz`, replace `cat` with `gunzip --stdout` # Replace with the database username - usually postgres unless you have changed it. +# Replace with the database name - usually immich unless you have changed it. -cat "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname=postgres --username= +cat "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname= --username= --single-transaction --set ON_ERROR_STOP=on exit # Exit the Docker shell docker compose up -d # Start remainder of Immich apps ``` @@ -188,6 +192,10 @@ docker compose up -d # Start remainder of Immich ap +:::warning +The backup and restore process changed in v2.5.0, if you have a backup created with an older version of Immich, use the documentation version selector to find manual restore instructions for your backup. +::: + :::note For the database restore to proceed properly, it requires a completely fresh install (i.e., the Immich server has never run since creating the Docker containers). If the Immich app has run, you may encounter Postgres conflicts (relation already exists, violated foreign key constraints, etc.). In this case, delete the `DB_DATA_LOCATION` folder to reset the database. ::: @@ -196,6 +204,10 @@ For the database restore to proceed properly, it requires a completely fresh ins Some deployment methods make it difficult to start the database without also starting the server. In these cases, set the environment variable `DB_SKIP_MIGRATIONS=true` before starting the services. This prevents the server from running migrations that interfere with the restore process. Remove this variable and restart services after the database is restored. ::: +:::tip +The provided restore process ensures your database is never in a broken state by committing all changes in one transaction. This may be undesirable behaviour in some circumstances, you can disable it by removing `--single-transaction --set ON_ERROR_STOP=on` from the command. +::: + ## Filesystem Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders: diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 47f4a96c6a..d0a9ce733e 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -56,11 +56,13 @@ Once you have a new OAuth client application configured, Immich can be configure | Setting | Type | Default | Description | | ---------------------------------------------------- | ------- | -------------------- | ----------------------------------------------------------------------------------- | | Enabled | boolean | false | Enable/disable OAuth | -| Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) | -| Client ID | string | (required) | Required. Client ID (from previous step) | -| Client Secret | string | (required) | Required. Client Secret (previous step) | -| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) | -| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) | +| `issuer_url` | URL | (required) | Required. Self-discovery URL for client (from previous step) | +| `client_id` | string | (required) | Required. Client ID (from previous step) | +| `client_secret` | string | (required) | Required. Client Secret (previous step) | +| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) | +| `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) | +| `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) | +| Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up | | Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** | | Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** | | Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** | diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index 4fc354aad7..84681fdfa6 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -88,7 +88,7 @@ The easiest option is to have both extensions installed during the migration:
Migration steps (automatic) 1. Ensure you still have pgvecto.rs installed -2. Install `pgvector` (`>= 0.7.0, < 1.0.0`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`) +2. Install `pgvector` (`>= 0.7, < 0.9`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`) 3. [Install VectorChord][vchord-install] 4. 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 5. Restart the Postgres database diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index 8dd1674448..b53356139f 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -98,7 +98,6 @@ entryPoints: respondingTimeouts: readTimeout: 600s idleTimeout: 600s - writeTimeout: 600s ``` The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example. diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index 8262d6a0d0..4bbf71dd89 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -90,10 +90,13 @@ To see local changes to `@immich/ui` in Immich, do the following: #### Setup -1. Setup Flutter toolchain using FVM. -2. Run `flutter pub get` to install the dependencies. -3. Run `make translation` to generate the translation file. -4. Run `fvm flutter run` to start the app. +1. [Install mise](https://mise.jdx.dev/installing-mise.html). +2. Change to the immich (root) directory and trust the mise config with `mise trust`. +3. Install tools with mise: `mise install`. +4. Change to the `mobile/` directory. +5. Run `flutter pub get` to install the dependencies. +6. Run `make translation` to generate the translation file. +7. Run `flutter run` to start the app. #### Translation diff --git a/docs/docs/features/command-line-interface.md b/docs/docs/features/command-line-interface.md index 4b477600f4..03e96e5080 100644 --- a/docs/docs/features/command-line-interface.md +++ b/docs/docs/features/command-line-interface.md @@ -183,7 +183,7 @@ For example to get a list of files that would be uploaded for further processing: ```bash -immich upload --dry-run . | tail -n +6 | jq .newFiles[] +immich upload --dry-run --json-output . | tail -n +6 | jq .newFiles[] ``` ### Obtain the API Key diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index d2a25bf4b6..795926ac89 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,7 +1,7 @@ [ { - "label": "v2.5.2", - "url": "https://docs.v2.5.2.archive.immich.app" + "label": "v2.5.5", + "url": "https://docs.v2.5.5.archive.immich.app" }, { "label": "v2.4.1", diff --git a/e2e/docker-compose.dev.yml b/e2e/docker-compose.dev.yml index cd1d3d4982..14e159ed50 100644 --- a/e2e/docker-compose.dev.yml +++ b/e2e/docker-compose.dev.yml @@ -70,7 +70,7 @@ services: restart: unless-stopped redis: - image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb + image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef database: image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index a33cb6573c..a98a7013a4 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -42,7 +42,7 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb + image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef database: image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 diff --git a/e2e/package.json b/e2e/package.json index b3973eb8bf..7271a65ffa 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "2.5.2", + "version": "2.5.5", "description": "", "main": "index.js", "type": "module", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index ab3252c40b..d4eee16232 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -473,6 +473,7 @@ describe('/asset', () => { id: user1Assets[0].id, exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', + timeZone: 'UTC-7', }), }); expect(status).toEqual(200); diff --git a/e2e/src/generators/memory.ts b/e2e/src/generators/memory.ts new file mode 100644 index 0000000000..c17b4aa476 --- /dev/null +++ b/e2e/src/generators/memory.ts @@ -0,0 +1,2 @@ +export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects'; +export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects'; diff --git a/e2e/src/generators/memory/model-objects.ts b/e2e/src/generators/memory/model-objects.ts new file mode 100644 index 0000000000..1bcc703ed8 --- /dev/null +++ b/e2e/src/generators/memory/model-objects.ts @@ -0,0 +1,84 @@ +import { faker } from '@faker-js/faker'; +import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { toAssetResponseDto } from 'src/generators/timeline/rest-response'; +import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config'; +import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils'; + +export type MemoryConfig = { + id?: string; + ownerId: string; + year: number; + memoryAt: string; + isSaved?: boolean; +}; + +export type MemoryYearConfig = { + year: number; + assetCount: number; +}; + +export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto { + const now = new Date().toISOString(); + const memoryId = config.id ?? faker.string.uuid(); + + return { + id: memoryId, + assets: assets.map((asset) => toAssetResponseDto(asset)), + data: { year: config.year } as OnThisDayDto, + memoryAt: config.memoryAt, + createdAt: now, + updatedAt: now, + isSaved: config.isSaved ?? false, + ownerId: config.ownerId, + type: MemoryType.OnThisDay, + }; +} + +export function generateMemoriesFromTimeline( + timelineAssets: MockTimelineAsset[], + ownerId: string, + memoryConfigs: MemoryYearConfig[], + seed: number = 42, +): MemoryResponseDto[] { + const rng = new SeededRandom(seed); + const memories: MemoryResponseDto[] = []; + const usedAssetIds = new Set(); + + for (const config of memoryConfigs) { + const yearAssets = timelineAssets.filter((asset) => { + const assetYear = DateTime.fromISO(asset.fileCreatedAt).year; + return assetYear === config.year && !usedAssetIds.has(asset.id); + }); + + if (yearAssets.length === 0) { + continue; + } + + const countToSelect = Math.min(config.assetCount, yearAssets.length); + const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng); + + for (const asset of selectedAssets) { + usedAssetIds.add(asset.id); + } + + selectedAssets.sort( + (a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds, + ); + + const memoryAt = DateTime.now().set({ year: config.year }).toISO()!; + + memories.push( + generateMemory( + { + ownerId, + year: config.year, + memoryAt, + }, + selectedAssets, + ), + ); + } + + return memories; +} diff --git a/e2e/src/mock-network/memory-network.ts b/e2e/src/mock-network/memory-network.ts new file mode 100644 index 0000000000..9a3a9e6555 --- /dev/null +++ b/e2e/src/mock-network/memory-network.ts @@ -0,0 +1,65 @@ +import type { MemoryResponseDto } from '@immich/sdk'; +import { BrowserContext } from '@playwright/test'; + +export type MemoryChanges = { + memoryDeletions: string[]; + assetRemovals: Map; +}; + +export const setupMemoryMockApiRoutes = async ( + context: BrowserContext, + memories: MemoryResponseDto[], + changes: MemoryChanges, +) => { + await context.route('**/api/memories*', async (route, request) => { + const url = new URL(request.url()); + const pathname = url.pathname; + + if (pathname === '/api/memories' && request.method() === 'GET') { + const activeMemories = memories + .filter((memory) => !changes.memoryDeletions.includes(memory.id)) + .map((memory) => { + const removedAssets = changes.assetRemovals.get(memory.id) ?? []; + return { + ...memory, + assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)), + }; + }) + .filter((memory) => memory.assets.length > 0); + + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: activeMemories, + }); + } + + const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/); + if (memoryMatch && request.method() === 'GET') { + const memoryId = memoryMatch[1]; + const memory = memories.find((m) => m.id === memoryId); + + if (!memory || changes.memoryDeletions.includes(memoryId)) { + return route.fulfill({ status: 404 }); + } + + const removedAssets = changes.assetRemovals.get(memoryId) ?? []; + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + ...memory, + assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)), + }, + }); + } + + if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') { + const memoryId = pathname.split('/').pop()!; + changes.memoryDeletions.push(memoryId); + return route.fulfill({ status: 204 }); + } + + await route.fallback(); + }); +}; diff --git a/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts b/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts new file mode 100644 index 0000000000..11e73fbe25 --- /dev/null +++ b/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts @@ -0,0 +1,289 @@ +import { faker } from '@faker-js/faker'; +import type { MemoryResponseDto } from '@immich/sdk'; +import { test } from '@playwright/test'; +import { generateMemoriesFromTimeline } from 'src/generators/memory'; +import { + Changes, + createDefaultTimelineConfig, + generateTimelineData, + TimelineAssetConfig, + TimelineData, +} from 'src/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; +import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network'; +import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; +import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { + let adminUserId: string; + let timelineRestData: TimelineData; + let memories: MemoryResponseDto[]; + const assets: TimelineAssetConfig[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + const memoryChanges: MemoryChanges = { + memoryDeletions: [], + assetRemovals: new Map(), + }; + + test.beforeAll(async () => { + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + + timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: adminUserId, + }); + + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + + memories = generateMemoriesFromTimeline( + assets, + adminUserId, + [ + { year: 2024, assetCount: 3 }, + { year: 2023, assetCount: 2 }, + { year: 2022, assetCount: 4 }, + ], + 42, + ); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + await setupMemoryMockApiRoutes(context, memories, memoryChanges); + }); + + test.afterEach(() => { + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + memoryChanges.memoryDeletions = []; + memoryChanges.assetRemovals.clear(); + }); + + test.describe('Asset viewer navigation from gallery', () => { + test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => { + const firstMemory = memories[0]; + const middleAsset = firstMemory.assets[1]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id); + await memoryGalleryUtils.clickThumbnail(page, middleAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => { + const firstMemory = memories[0]; + const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory); + + await memoryAssetViewerUtils.expectNextButtonVisible(page); + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + }); + + test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => { + const lastMemory = memories.at(-1)!; + const firstAssetOfLastMemory = lastMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + + await memoryAssetViewerUtils.clickNextButton(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id); + }); + + test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await memoryAssetViewerUtils.clickPreviousButton(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + }); + + test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => { + const firstMemory = memories[0]; + const veryFirstAsset = firstMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id); + await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset); + + await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => { + const lastMemory = memories.at(-1)!; + const veryLastAsset = lastMemory.assets.at(-1)!; + + await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id); + await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset); + + await memoryAssetViewerUtils.expectNextButtonNotVisible(page); + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + }); + }); + + test.describe('Keyboard navigation', () => { + test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await page.keyboard.press('ArrowLeft'); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + }); + + test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + + await page.keyboard.press('ArrowRight'); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + }); + }); +}); + +test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => { + let adminUserId: string; + let timelineRestData: TimelineData; + let memories: MemoryResponseDto[]; + const assets: TimelineAssetConfig[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + const memoryChanges: MemoryChanges = { + memoryDeletions: [], + assetRemovals: new Map(), + }; + + test.beforeAll(async () => { + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + + timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: adminUserId, + }); + + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + + memories = generateMemoriesFromTimeline( + assets, + adminUserId, + [ + { year: 2024, assetCount: 2 }, + { year: 2023, assetCount: 1 }, + { year: 2022, assetCount: 2 }, + ], + 123, + ); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + await setupMemoryMockApiRoutes(context, memories, memoryChanges); + }); + + test.afterEach(() => { + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + memoryChanges.memoryDeletions = []; + memoryChanges.assetRemovals.clear(); + }); + + test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => { + const singleAssetMemory = memories[1]; + const singleAsset = singleAssetMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id); + await memoryGalleryUtils.clickThumbnail(page, singleAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); +}); diff --git a/e2e/src/web/specs/memory/utils.ts b/e2e/src/web/specs/memory/utils.ts new file mode 100644 index 0000000000..cf99033e7e --- /dev/null +++ b/e2e/src/web/specs/memory/utils.ts @@ -0,0 +1,123 @@ +import type { AssetResponseDto } from '@immich/sdk'; +import { expect, Page } from '@playwright/test'; + +function getAssetIdFromUrl(url: URL): string | null { + const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/); + if (pathMatch) { + return pathMatch[1]; + } + return url.searchParams.get('id'); +} + +export const memoryViewerUtils = { + locator(page: Page) { + return page.locator('#memory-viewer'); + }, + + async waitForMemoryLoad(page: Page) { + await expect(this.locator(page)).toBeVisible(); + await expect(page.locator('#memory-viewer img').first()).toBeVisible(); + }, + + async openMemoryPage(page: Page) { + await page.goto('/memory'); + await this.waitForMemoryLoad(page); + }, + + async openMemoryPageWithAsset(page: Page, assetId: string) { + await page.goto(`/memory?id=${assetId}`); + await this.waitForMemoryLoad(page); + }, +}; + +export const memoryGalleryUtils = { + locator(page: Page) { + return page.locator('#gallery-memory'); + }, + + thumbnailWithAssetId(page: Page, assetId: string) { + return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`); + }, + + async scrollToGallery(page: Page) { + const showGalleryButton = page.getByLabel('Show gallery'); + if (await showGalleryButton.isVisible()) { + await showGalleryButton.click(); + } + await expect(this.locator(page)).toBeInViewport(); + }, + + async clickThumbnail(page: Page, assetId: string) { + await this.scrollToGallery(page); + await this.thumbnailWithAssetId(page, assetId).click(); + }, + + async getAllThumbnails(page: Page) { + await this.scrollToGallery(page); + return page.locator('#gallery-memory [data-thumbnail-focus-container]'); + }, +}; + +export const memoryAssetViewerUtils = { + locator(page: Page) { + return page.locator('#immich-asset-viewer'); + }, + + async waitForViewerOpen(page: Page) { + await expect(this.locator(page)).toBeVisible(); + }, + + async waitForAssetLoad(page: Page, asset: AssetResponseDto) { + const viewer = this.locator(page); + const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`); + const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`); + + await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 }); + }, + + nextButton(page: Page) { + return page.getByLabel('View next asset'); + }, + + previousButton(page: Page) { + return page.getByLabel('View previous asset'); + }, + + async expectNextButtonVisible(page: Page) { + await expect(this.nextButton(page)).toBeVisible(); + }, + + async expectNextButtonNotVisible(page: Page) { + await expect(this.nextButton(page)).toHaveCount(0); + }, + + async expectPreviousButtonVisible(page: Page) { + await expect(this.previousButton(page)).toBeVisible(); + }, + + async expectPreviousButtonNotVisible(page: Page) { + await expect(this.previousButton(page)).toHaveCount(0); + }, + + async clickNextButton(page: Page) { + await this.nextButton(page).click(); + }, + + async clickPreviousButton(page: Page) { + await this.previousButton(page).click(); + }, + + async closeViewer(page: Page) { + await page.keyboard.press('Escape'); + await expect(this.locator(page)).not.toBeVisible(); + }, + + getCurrentAssetId(page: Page): string | null { + const url = new URL(page.url()); + return getAssetIdFromUrl(url); + }, + + async expectCurrentAssetId(page: Page, expectedAssetId: string) { + await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId); + }, +}; diff --git a/i18n/ar.json b/i18n/ar.json index 968f9e02e7..6702d4c695 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -5,7 +5,7 @@ "acknowledge": "أُدرك ذلك", "action": "عملية", "action_common_update": "تحديث", - "action_description": "مجموعة من الفعاليات التي يجب تنفيذها على الأصول التي تم تصفيتها", + "action_description": "مجموعة من الفعاليات التي ستنفذ على الأصول التي تم تصفيتها", "actions": "عمليات", "active": "نشط", "active_count": "فعال: {count}", @@ -272,7 +272,7 @@ "oauth_auto_register": "التسجيل التلقائي", "oauth_auto_register_description": "التسجيل التلقائي للمستخدمين الجدد بعد تسجيل الدخول باستخدام OAuth", "oauth_button_text": "نص الزر", - "oauth_client_secret_description": "مطلوب اذاPKCE(مفتاح الاثبات لتبادل الكود) لم يتم توفيره من مزود OAuth", + "oauth_client_secret_description": "مطلوب للعميل السري، او اذا PKCE(مفتاح الاثبات لتبادل الكود) ليس مدعوم من العميل العام.", "oauth_enable_description": "تسجيل الدخول باستخدام OAuth", "oauth_mobile_redirect_uri": "عنوان URI لإعادة التوجيه على الهاتف", "oauth_mobile_redirect_uri_override": "تجاوز عنوان URI لإعادة التوجيه على الهاتف", @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "تصميم", "asset_list_settings_subtitle": "إعدادات تخطيط شبكة الصور", "asset_list_settings_title": "شبكة الصور", + "asset_not_found_on_device_android": "الاصل لم يتم ايجاده في الجهاز", + "asset_not_found_on_device_ios": "الأصل لم يتم ايجاده في الجهاز. اذا تستخدم خدمة iCloud, فالأصل قد لا يتم الوصول له بسبب ملف متضارب مخزون في iCloud", + "asset_not_found_on_icloud": "الأصل لم يتم ايجاده في الجهاز, الأصل قد لا يتم الوصول له بسبب ملف متضارب مخزون في iCloud", "asset_offline": "المحتوى غير اتصال", "asset_offline_description": "لم يعد هذا الأصل الخارجي موجودًا على القرص. يرجى الاتصال بمسؤول Immich للحصول على المساعدة.", "asset_restored_successfully": "تم استعادة الاصل بنجاح", @@ -650,7 +653,7 @@ "backup_controller_page_background_turn_off": "قم بإيقاف تشغيل خدمة الخلفية", "backup_controller_page_background_turn_on": "قم بتشغيل خدمة الخلفية", "backup_controller_page_background_wifi": "فقط على Wi-Fi", - "backup_controller_page_backup": "دعم", + "backup_controller_page_backup": "نسخ احتياطي", "backup_controller_page_backup_selected": "المحدد: ", "backup_controller_page_backup_sub": "النسخ الاحتياطي للصور ومقاطع الفيديو", "backup_controller_page_created": "انشئ في :{date}", @@ -1192,7 +1195,6 @@ "features": "الميزات", "features_in_development": "الميزات قيد التطوير", "features_setting_description": "إدارة ميزات التطبيق", - "file_name": "اسم الملف: {file_name}", "file_name_or_extension": "اسم الملف أو امتداده", "file_size": "حجم الملف", "filename": "اسم الملف", @@ -2295,6 +2297,7 @@ "upload_details": "تفاصيل الرفع", "upload_dialog_info": "هل تريد النسخ الاحتياطي للأصول (الأصول) المحددة إلى الخادم؟", "upload_dialog_title": "تحميل الأصول", + "upload_error_with_count": "خطأ في رفع {count, plural, one {# اصل} other {# اصول}}", "upload_errors": "إكتمل الرفع مع {count, plural, one {# خطأ} other {# أخطاء}}, قم بتحديث الصفحة لرؤية المحتويات الجديدة التي تم رفعها.", "upload_finished": "تم الانتهاء من الرفع", "upload_progress": "متبقية {remaining, number} - معالجة {processed, number}/{total, number}", diff --git a/i18n/be.json b/i18n/be.json index bd692531cc..13ac6747f1 100644 --- a/i18n/be.json +++ b/i18n/be.json @@ -380,7 +380,6 @@ "favorite": "У абраным", "favorite_or_unfavorite_photo": "Дадаць або выдаліць фота з абранага", "favorites": "Абраныя", - "file_name": "Назва файла: {file_name}", "filename": "Назва файла", "filetype": "Тып файла", "filter": "Фільтр", diff --git a/i18n/bg.json b/i18n/bg.json index 832e3e22fc..c1cf0abdf5 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -272,7 +272,7 @@ "oauth_auto_register": "Автоматична регистрация", "oauth_auto_register_description": "Автоматично регистриране на нови потребители след влизане с OAuth", "oauth_button_text": "Текст на бутона", - "oauth_client_secret_description": "Изисква се, когато доставчика на OAuth не поддържа PKCE (Proof Key for Code Exchange)", + "oauth_client_secret_description": "Задължително за поверителен клиент или когато PKCE (Proof Key for Code Exchange) не се поддържа за публичен клиент.", "oauth_enable_description": "Влизане с OAuth", "oauth_mobile_redirect_uri": "URI за мобилно пренасочване", "oauth_mobile_redirect_uri_override": "URI пренасочване за мобилни устройства", @@ -383,7 +383,7 @@ "transcoding_hardware_acceleration": "Хардуерно ускорение", "transcoding_hardware_acceleration_description": "Експериментално: много по-бързо транскодиране, но може да понижи качеството при същия битрейт", "transcoding_hardware_decoding": "Хардуерно декодиране", - "transcoding_hardware_decoding_setting_description": "Прилага се само за NVENC, QSV и RKMPP. Активира ускорение от край до край, вместо само да ускорява кодирането. Може да не работи с всички видеоклипове.", + "transcoding_hardware_decoding_setting_description": "Активира ускорение от край до край, вместо само да ускорява кодирането. Може да не работи с всички видеоклипове.", "transcoding_max_b_frames": "Максимални B-фрейма", "transcoding_max_b_frames_description": "По-високите стойности подобряват ефективността на компресията, но забавят разкодирането. Може да не е съвместим с хардуерното ускорение на по-стари устройства. 0 деактивира B-фрейма, докато -1 задава тази стойност автоматично.", "transcoding_max_bitrate": "Максимален битрейт", @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Разположение", "asset_list_settings_subtitle": "Настройки на мрежата на разполагане на снимки", "asset_list_settings_title": "Разполагане на снимки", + "asset_not_found_on_device_android": "Активът не е намерен на устройството", + "asset_not_found_on_device_ios": "Обектът не е намерен на устройството. Ако използвате iCloud, обектът може да е недостъпен поради повреден файл, съхранен в iCloud", + "asset_not_found_on_icloud": "Обектът не е намерен в iCloud. Обектът може да е недостъпен поради повреден файл, съхранен в iCloud", "asset_offline": "Елементът е офлайн", "asset_offline_description": "Този външен актив вече не се намира на диска. Моля, свържете се с администратора на Immich за помощ.", "asset_restored_successfully": "Успешно възстановен обект", @@ -1149,7 +1152,7 @@ }, "errors_text": "Грешки", "exclusion_pattern": "Шаблон за изключение", - "exif": "Exif", + "exif": "Еxif", "exif_bottom_sheet_description": "Добави Описание...", "exif_bottom_sheet_description_error": "Неуспешно обновяване на описание", "exif_bottom_sheet_details": "ПОДРОБНОСТИ", @@ -1192,7 +1195,6 @@ "features": "Функции", "features_in_development": "Функции в процес на разработка", "features_setting_description": "Управление на функциите на приложението", - "file_name": "Име на файла: {file_name}", "file_name_or_extension": "Име на файл или разширение", "file_size": "Размер на файла", "filename": "Име на файл", @@ -1215,7 +1217,7 @@ "free_up_space_description": "Преместете архивираните снимки и видеа в кошчето на устройството, за да освободите място. Копията на сървъра ще бъдат запазени.", "free_up_space_settings_subtitle": "Освобождаване на място за съхранение на устройството", "full_path": "Пълен път: {path}", - "gcast_enabled": "Google Cast", + "gcast_enabled": "Gооgle Cast", "gcast_enabled_description": "За да работи тази функция зарежда външни ресурси от Google.", "general": "Общи", "geolocation_instruction_location": "Изберете обект с GPS координати за да използвате тях или изберете място директно от картата", @@ -1404,7 +1406,7 @@ "login_form_api_exception": "Грешка в комуникацията. Моля, провери URL на сървъра и опитай пак.", "login_form_back_button_text": "Обратно", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port", + "login_form_endpoint_hint": "http://yоur-server-ip:port", "login_form_endpoint_url": "URL адрес на сървъра", "login_form_err_http": "Моля, определи протокола http:// или https://", "login_form_err_invalid_email": "Невалиден имейл адрес", @@ -2295,6 +2297,7 @@ "upload_details": "Детайли за качването", "upload_dialog_info": "Искате ли да архивирате на сървъра избраните обекти?", "upload_dialog_title": "Качи обект", + "upload_error_with_count": "Грешка при зареждане на {count, plural, one {# обект} other {# обекта}}", "upload_errors": "Качването е завъшено с {count, plural, one {# грешка} other {# грешки}}, обновете страницата за да видите новите елементи.", "upload_finished": "Качването завърши", "upload_progress": "Остават {remaining, number} - Обработени {processed, number}/{total, number}", diff --git a/i18n/bn.json b/i18n/bn.json index 0639e4e681..2ed2b39338 100644 --- a/i18n/bn.json +++ b/i18n/bn.json @@ -40,7 +40,9 @@ "add_to_albums_count": "অ্যালবামে যোগ করুন ({count})", "add_to_bottom_bar": "এ যোগ করুন", "add_to_shared_album": "শেয়ার করা অ্যালবামে যোগ করুন", + "add_upload_to_stack": "আপলোড স্ট্যাকে যোগ করুন", "add_url": "লিঙ্ক যোগ করুন", + "add_workflow_step": "কাজের ধাপ যোগ করুন", "added_to_archive": "আর্কাইভ এ যোগ করা হয়েছে", "added_to_favorites": "ফেভারিটে যোগ করা হয়েছে", "added_to_favorites_count": "পছন্দের তালিকায় {count, number} যোগ করা হয়েছে", @@ -73,6 +75,7 @@ "confirm_reprocess_all_faces": "আপনি কি নিশ্চিত যে আপনি সমস্ত মুখ পুনরায় প্রক্রিয়া করতে চান? এটি নামযুক্ত ব্যক্তিদেরও মুছে ফেলবে।", "confirm_user_password_reset": "আপনি কি নিশ্চিত যে আপনি {user} এর পাসওয়ার্ড রিসেট করতে চান?", "confirm_user_pin_code_reset": "আপনি কি নিশ্চিত যে আপনি {user} এর পিন কোড রিসেট করতে চান?", + "copy_config_to_clipboard_description": "বর্তমান সিস্টেম কনফিগারেশন একটি JSON অবজেক্ট হিসেবে ক্লিপবোর্ডে কপি করুন", "create_job": "job তৈরি করুন", "cron_expression": "ক্রোন এক্সপ্রেশন", "cron_expression_description": "ক্রোন ফর্ম্যাট ব্যবহার করে স্ক্যানিং ব্যবধান সেট করুন। আরও তথ্যের জন্য দয়া করে দেখুন যেমন Crontab Guru", @@ -80,6 +83,8 @@ "disable_login": "লগইন অক্ষম করুন", "duplicate_detection_job_description": "অনুরূপ ছবি সনাক্ত করতে সম্পদগুলিতে মেশিন লার্নিং চালান। স্মার্ট অনুসন্ধানের উপর নির্ভর করে", "exclusion_pattern_description": "এক্সক্লুশন প্যাটার্ন ব্যবহার করে আপনি আপনার লাইব্রেরি স্ক্যান করার সময় ফাইল এবং ফোল্ডারগুলিকে উপেক্ষা করতে পারবেন। যদি আপনার এমন ফোল্ডার থাকে যেখানে এমন ফাইল থাকে যা আপনি আমদানি করতে চান না, যেমন RAW ফাইল।", + "export_config_as_json_description": "বর্তমান সিস্টেম কনফিগারেশন একটি JSON ফাইল হিসেবে ডাউনলোড করুন", + "external_libraries_page_description": "অ্যাডমিন external লাইব্রেরি পেজ", "face_detection": "মুখ সনাক্তকরণ", "face_detection_description": "মেশিন লার্নিং ব্যবহার করে অ্যাসেটে থাকা মুখ/চেহারা গুলি সনাক্ত করুন। ভিডিও গুলির জন্য, শুধুমাত্র থাম্বনেইল বিবেচনা করা হয়। \"রিফ্রেশ\" (পুনরায়) সমস্ত অ্যাসেট প্রক্রিয়া করে। \"রিসেট\" করার মাধ্যমে অতিরিক্তভাবে সমস্ত বর্তমান মুখের ডেটা সাফ করে। \"অনুপস্থিত\" অ্যাসেটগুলিকে সারিবদ্ধ করে যা এখনও প্রক্রিয়া করা হয়নি। সনাক্ত করা মুখগুলিকে ফেসিয়াল রিকগনিশনের জন্য সারিবদ্ধ করা হবে, ফেসিয়াল ডিটেকশন সম্পূর্ণ হওয়ার পরে, বিদ্যমান বা নতুন ব্যক্তিদের মধ্যে গোষ্ঠীবদ্ধ করে।", "facial_recognition_job_description": "শনাক্ত করা মুখগুলিকে মানুষের মধ্যে গোষ্ঠীভুক্ত/গ্রুপ করুন। মুখ সনাক্তকরণ সম্পূর্ণ হওয়ার পরে এই ধাপটি চলে। \"রিসেট\" (পুনরায়) সমস্ত মুখকে ক্লাস্টার করে। \"অনুপস্থিত/মিসিং\" মুখগুলিকে সারিতে রাখে যেগুলো কোনও ব্যক্তিকে এসাইন/বরাদ্দ করা হয়নি।", @@ -99,6 +104,8 @@ "image_preview_description": "স্ট্রিপড মেটাডেটা সহ মাঝারি আকারের ছবি, একটি একক সম্পদ দেখার সময় এবং মেশিন লার্নিংয়ের জন্য ব্যবহৃত হয়", "image_preview_quality_description": "১-১০০ এর মধ্যে প্রিভিউ কোয়ালিটি। বেশি হলে ভালো, কিন্তু বড় ফাইল তৈরি হয় এবং অ্যাপের প্রতিক্রিয়াশীলতা কমাতে পারে। কম মান সেট করলে মেশিন লার্নিং কোয়ালিটির উপর প্রভাব পড়তে পারে।", "image_preview_title": "প্রিভিউ সেটিংস", + "image_progressive": "প্রগ্রেসিভ", + "image_progressive_description": "ধীরে ধীরে লোড হওয়ার সুবিধার্থে JPEG ছবিগুলো প্রগ্রেসিভভাবে এনকোড করুন। WebP ছবির ক্ষেত্রে এটি কোনো প্রভাব ফেলবে না", "image_quality": "গুণমান", "image_resolution": "রেজোলিউশন", "image_resolution_description": "উচ্চ রেজোলিউশনের ক্ষেত্রে আরও বিস্তারিত তথ্য সংরক্ষণ করা সম্ভব কিন্তু এনকোড করতে বেশি সময় লাগে, ফাইলের আকার বড় হয় এবং অ্যাপের প্রতিক্রিয়াশীলতা কমাতে পারে।", @@ -107,6 +114,7 @@ "image_thumbnail_description": "মেটাডেটা বাদ দেওয়া ছোট থাম্বনেইল, মূল টাইমলাইনের মতো ছবির গ্রুপ দেখার সময় ব্যবহৃত হয়", "image_thumbnail_quality_description": "থাম্বনেইলের মান ১-১০০। বেশি হলে ভালো, কিন্তু বড় ফাইল তৈরি হয় এবং অ্যাপের প্রতিক্রিয়াশীলতা কমাতে পারে।", "image_thumbnail_title": "থাম্বনেল সেটিংস", + "import_config_from_json_description": "একটি JSON কনফিগ ফাইল আপলোড করে সিস্টেম কনফিগারেশন ইমপোর্ট করুন।", "job_concurrency": "{job} কনকারেন্সি", "job_created": "Job তৈরি হয়েছে", "job_not_concurrency_safe": "এই কাজটি সমান্তরালভাবে চালানো নিরাপদ নয়", @@ -114,14 +122,20 @@ "job_settings_description": "কাজের সমান্তরালতা পরিচালনা করুন", "jobs_delayed": "{jobCount, plural, other {# বিলম্বিত}}", "jobs_failed": "{jobCount, plural, other {# ব্যর্থ}}", + "jobs_over_time": "সময় অনুযায়ী কাজসমূহ", "library_created": "লাইব্রেরি তৈরি করা হয়েছেঃ {library}", "library_deleted": "লাইব্রেরি মুছে ফেলা হয়েছে", + "library_details": "লাইব্রেরির বিবরণ", + "library_folder_description": "ইমপোর্ট করার জন্য একটি ফোল্ডার নির্দিষ্ট করুন। এই ফোল্ডার এবং এর ভেতরের সমস্ত ফোল্ডার ছবি ও ভিডিওর জন্য স্ক্যান করা হবে।", + "library_remove_exclusion_pattern_prompt": "আপনি কি নিশ্চিত যে আপনি এই এক্সক্লুশন প্যাটার্নটি মুছে ফেলতে চান?", + "library_remove_folder_prompt": "আপনি কি নিশ্চিত যে আপনি এই ইমপোর্ট ফোল্ডারটি মুছে ফেলতে চান?", "library_scanning": "পর্যায়ক্রমিক স্ক্যানিং", "library_scanning_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং কনফিগার করুন", "library_scanning_enable_description": "পর্যায়ক্রমিক লাইব্রেরি স্ক্যানিং সক্ষম করুন", "library_settings": "বহিরাগত লাইব্রেরি", "library_settings_description": "বহিরাগত লাইব্রেরি সেটিংস পরিচালনা করুন", "library_tasks_description": "নতুন এবং/অথবা পরিবর্তিত সম্পদের জন্য বহিরাগত লাইব্রেরি স্ক্যান করুন", + "library_updated": "আপডেটকৃত লাইব্রেরি।", "library_watching_enable_description": "ফাইল পরিবর্তনের জন্য বহিরাগত লাইব্রেরিগুলি দেখুন", "library_watching_settings": "লাইব্রেরি দেখা (পরীক্ষামূলক)", "library_watching_settings_description": "পরিবর্তিত ফাইলগুলির জন্য স্বয়ংক্রিয়ভাবে নজর রাখুন", @@ -133,9 +147,190 @@ "machine_learning_availability_checks_enabled": "প্রাপ্যতা পরীক্ষা সক্ষম করুন", "machine_learning_availability_checks_interval": "চেক ব্যবধান", "machine_learning_availability_checks_interval_description": "প্রাপ্যতা পরীক্ষাগুলির মধ্যে ব্যবধান মিলিসেকেন্ডে", + "machine_learning_availability_checks_timeout": "অনুরোধের সময়সীমা শেষ", + "machine_learning_availability_checks_timeout_description": "প্রাপ্যতার পরীক্ষার জন্য মিলিসেকেন্ডে সময়সীমা।", "machine_learning_clip_model": "CLIP মডেল", "machine_learning_clip_model_description": "এখানে তালিকাভুক্ত একটি CLIP মডেলের নাম। মনে রাখবেন, মডেল পরিবর্তনের পর সব ছবির জন্য অবশ্যই ‘Smart Search’ কাজটি আবার চালাতে হবে।", "machine_learning_duplicate_detection": "পুনরাবৃত্তি সনাক্তকরণ", - "machine_learning_duplicate_detection_enabled": "পুনরাবৃত্তি শনাক্তকরণ চালু করুন" - } + "machine_learning_duplicate_detection_enabled": "পুনরাবৃত্তি শনাক্তকরণ চালু করুন", + "machine_learning_duplicate_detection_enabled_description": "নিষ্ক্রিয় থাকলেও হুবহু একই সম্পদগুলোর ডুপ্লিকেট সরিয়ে ফেলা হবে।", + "machine_learning_duplicate_detection_setting_description": "সম্ভাব্য ডুপ্লিকেট খুঁজে বের করতে CLIP এম্বেডিং ব্যবহার করুন।", + "machine_learning_enabled": "Machine Learning সক্ষম করুন", + "machine_learning_enabled_description": "নিষ্ক্রিয় থাকলে নিচের সেটিংস নির্বিশেষে সমস্ত ML বৈশিষ্ট্য নিষ্ক্রিয় করা হবে।", + "machine_learning_facial_recognition": "ফেসিয়াল রিকগনিশন", + "machine_learning_facial_recognition_description": "ছবিতে মুখ সনাক্ত করুন, চিনুন এবং গ্রুপ করুন।", + "machine_learning_facial_recognition_model": "ফেসিয়াল রিকগনিশন মডেল", + "machine_learning_facial_recognition_model_description": "মডেলগুলি আকারের অধঃক্রম অনুযায়ী তালিকাভুক্ত করা হয়েছে। বড় মডেলগুলি ধীরগতির এবং বেশি মেমরি ব্যবহার করে, তবে উন্নত ফলাফল প্রদান করে। মনে রাখবেন যে একটি মডেল পরিবর্তন করার পর আপনাকে সমস্ত ছবির জন্য ফেস ডিটেকশন (Face Detection) কাজটি পুনরায় চালাতে হবে।", + "machine_learning_facial_recognition_setting": "ফেসিয়াল রিকগনিশন সক্ষম করুন", + "machine_learning_facial_recognition_setting_description": "নিষ্ক্রিয় থাকলে, ফেসিয়াল রিকগনিশনের জন্য ছবিগুলো এনকোড করা হবে না এবং এক্সপ্লোর পেজের পিপল (People) সেকশনটি পূর্ণ হবে না।", + "machine_learning_max_detection_distance": "সর্বোচ্চ শনাক্তকরণ দূরত্ব", + "machine_learning_max_detection_distance_description": "দুটি ছবিকে ডুপ্লিকেট হিসেবে গণ্য করার জন্য তাদের মধ্যকার সর্বোচ্চ দূরত্ব, যার পরিসীমা ০.০০১-০.১। মান যত বেশি হবে তত বেশি ডুপ্লিকেট শনাক্ত হবে, তবে এতে ভুল শনাক্তকরণের (false positives) সম্ভাবনা থাকতে পারে।", + "machine_learning_max_recognition_distance": "সর্বোচ্চ চিহ্নিতকরণ দূরত্ব", + "machine_learning_max_recognition_distance_description": "দুটি মুখকে একই ব্যক্তি হিসেবে গণ্য করার জন্য তাদের মধ্যকার সর্বোচ্চ দূরত্ব, যার পরিসীমা ০-২। এই মান কমালে দু’জন ভিন্ন ব্যক্তিকে একই ব্যক্তি হিসেবে চিহ্নিত করার সম্ভাবনা কমে, আর মান বাড়ালে একই ব্যক্তিকে দু’জন ভিন্ন ব্যক্তি হিসেবে চিহ্নিত করার সম্ভাবনা কমে। মনে রাখবেন যে, দু’জন ব্যক্তিকে একত্রিত করা (merge) অপেক্ষাকৃত সহজ কিন্তু একজনকে দু’ভাগে ভাগ করা কঠিন, তাই সম্ভব হলে থ্রেশহোল্ড (threshold) কম রাখাই ভালো।", + "machine_learning_min_detection_score": "সর্বনিম্ন শনাক্তকরণ স্কোর", + "machine_learning_min_detection_score_description": "ছবিতে মুখ শনাক্ত করার জন্য ০-১ এর মধ্যে সর্বনিম্ন কনফিডেন্স স্কোর। মান যত কম হবে তত বেশি মুখ শনাক্ত হবে, তবে এতে ভুল শনাক্তকরণের (false positives) সম্ভাবনা থাকতে পারে।", + "machine_learning_min_recognized_faces": "সর্বনিম্ন স্বীকৃত মুখের সংখ্যা", + "machine_learning_min_recognized_faces_description": "একজন ব্যক্তি হিসেবে তৈরি হওয়ার জন্য স্বীকৃত মুখের সর্বনিম্ন সংখ্যা। এটি বাড়ালে ফেসিয়াল রিকগনিশন আরও নিখুঁত হয়, তবে এতে কোনো মুখ কোনো ব্যক্তির সাথে সংযুক্ত না হওয়ার সম্ভাবনাও বৃদ্ধি পায়।", + "machine_learning_ocr": "OCR", + "machine_learning_ocr_description": "ছবিতে টেক্সট (Text) শনাক্ত করতে মেশিন লার্নিং ব্যবহার করুন।", + "machine_learning_ocr_enabled": "OCR সক্ষম করুন", + "machine_learning_ocr_enabled_description": "নিষ্ক্রিয় থাকলে, ছবিগুলোতে টেক্সট শনাক্তকরণ করা হবে না।", + "machine_learning_ocr_max_resolution": "সর্বোচ্চ রেজোলিউশন(Resolution)", + "machine_learning_ocr_max_resolution_description": "এই রেজোলিউশনের উপরের প্রিভিউগুলোর অ্যাসপেক্ট রেশিও (আকার ও অনুপাত) ঠিক রেখে রিসাইজ করা হবে। মান যত বেশি হবে ফলাফল তত বেশি নিখুঁত হবে, তবে এটি প্রসেস করতে সময় বেশি লাগবে এবং মেমরি বেশি ব্যবহার করবে।", + "machine_learning_ocr_min_detection_score": "সর্বনিম্ন শনাক্তকরণ স্কোর", + "machine_learning_ocr_min_detection_score_description": "টেক্সট শনাক্ত করার জন্য ০-১ এর মধ্যে ন্যূনতম কনফিডেন্স স্কোর। মান যত কম হবে তত বেশি টেক্সট শনাক্ত হবে, তবে এতে ভুল শনাক্তকরণের (false positives) সম্ভাবনা থাকতে পারে।", + "machine_learning_ocr_min_recognition_score": "সর্বনিম্ন চিহ্নিতকরণ (Recognition)স্কোর", + "machine_learning_ocr_min_score_recognition_description": "শনাক্তকৃত টেক্সট চিহ্নিত করার জন্য ০-১ এর মধ্যে ন্যূনতম কনফিডেন্স স্কোর। মান যত কম হবে তত বেশি টেক্সট চিহ্নিত হবে, তবে এতে ভুল শনাক্তকরণের (false positives) সম্ভাবনা থাকতে পারে।", + "machine_learning_ocr_model": "OCR মডেল", + "machine_learning_ocr_model_description": "সার্ভার মডেলগুলো মোবাইল মডেলের তুলনায় বেশি নির্ভুল, তবে এগুলো প্রসেস করতে সময় বেশি লাগে এবং মেমরি বেশি ব্যবহার করে।", + "machine_learning_settings": "মেশিন লার্নিং সেটিংস (Machine Learning Settings)", + "machine_learning_settings_description": "মেশিন লার্নিং বৈশিষ্ট্য এবং সেটিংস পরিচালনা করুন", + "machine_learning_smart_search": "স্মার্ট সার্চ (Smart Search)", + "machine_learning_smart_search_description": "CLIP এমবেডিং (embeddings) ব্যবহার করে ছবির বিষয়বস্তু অনুযায়ী অনুসন্ধান করুন", + "machine_learning_smart_search_enabled": "স্মার্ট সার্চ সক্ষম করুন", + "machine_learning_smart_search_enabled_description": "নিষ্ক্রিয় থাকলে, স্মার্ট সার্চের জন্য ছবিগুলো এনকোড (encode) করা হবে না।", + "machine_learning_url_description": "মেশিন লার্নিং সার্ভারের URL। যদি একের বেশি URL প্রদান করা হয়, তবে একটি সফলভাবে সাড়া না দেওয়া পর্যন্ত প্রতিটি সার্ভারে এক এক করে চেষ্টা করা হবে (প্রথম থেকে শেষ ক্রমানুসারে)। যে সার্ভারগুলো সাড়া দেবে না, সেগুলো পুনরায় সচল হওয়া পর্যন্ত সাময়িকভাবে উপেক্ষা করা হবে।", + "maintenance_delete_backup": "ব্যাকআপ (Backup)মুছুন", + "maintenance_delete_backup_description": "এই ফাইলটি চিরতরে মুছে ফেলা হবে।", + "maintenance_delete_error": "ব্যাকআপ মুছতে ব্যর্থ হয়েছে।", + "maintenance_restore_backup": "ব্যাকআপ পুনরুদ্ধার(Restore) করুন", + "maintenance_restore_backup_description": "Immich মুছে ফেলা হবে এবং নির্বাচিত ব্যাকআপ থেকে পুনরুদ্ধার করা হবে। কার্যক্রম চালিয়ে যাওয়ার আগে একটি ব্যাকআপ তৈরি করা হবে।", + "maintenance_restore_backup_different_version": "এই ব্যাকআপটি Immich-এর একটি ভিন্ন সংস্করণের মাধ্যমে তৈরি করা হয়েছিল!", + "maintenance_restore_backup_unknown_version": "ব্যাকআপ সংস্করণ নির্ধারণ করা সম্ভব হয়নি।", + "maintenance_restore_database_backup": "ডেটাবেস ব্যাকআপ পুনরুদ্ধার করুন", + "maintenance_restore_database_backup_description": "একটি ব্যাকআপ ফাইল ব্যবহার করে ডেটাবেসকে পূর্ববর্তী অবস্থায় ফিরিয়ে আনুন।", + "maintenance_settings": "রক্ষণাবেক্ষণ (Maintenance)", + "maintenance_settings_description": "Immich-কে রক্ষণাবেক্ষণ মোডে (maintenance mode) রাখুন।", + "maintenance_start": "রক্ষণাবেক্ষণ মোডে পরিবর্তন করুন", + "maintenance_start_error": "রক্ষণাবেক্ষণ মোড চালু করতে ব্যর্থ হয়েছে।", + "maintenance_upload_backup": "ডেটাবেস ব্যাকআপ ফাইল আপলোড করুন", + "maintenance_upload_backup_error": "ব্যাকআপ আপলোড করা যায়নি, এটি কি কোনো .sql/.sql.gz ফাইল?", + "manage_concurrency": "কনকারেন্সি পরিচালনা করুন (Manage Concurrency)", + "manage_concurrency_description": "জব কনকারেন্সি পরিচালনা করতে 'জবস' (Jobs) পাতায় যান।", + "manage_log_settings": "লগ সেটিংস পরিচালনা করুন", + "map_dark_style": "ডার্ক স্টাইল (Dark style)", + "map_enable_description": "ম্যাপ ফিচারগুলো সক্রিয় করুন (Enable map features)", + "map_gps_settings": "ম্যাপ এবং জিপিএস সেটিংস (Map & GPS Settings)", + "map_gps_settings_description": "ম্যাপ এবং জিপিএস (রিভার্স জিওকোডিং) সেটিংস পরিচালনা করুন (Manage Map & GPS (Reverse Geocoding) Settings)", + "map_implications": "ম্যাপ ফিচারটি একটি এক্সটার্নাল টাইল সার্ভিসের (tiles.immich.cloud) ওপর নির্ভর করে।", + "map_light_style": "লাইট স্টাইল (Light style)", + "map_manage_reverse_geocoding_settings": "রিভার্স জিওকোডিং সেটিংস পরিচালনা করুন", + "map_reverse_geocoding": "রিভার্স জিওকোডিং (Reverse Geocoding)", + "map_reverse_geocoding_enable_description": "রিভার্স জিওকোডিং সক্রিয় করুন (Enable reverse geocoding)", + "map_reverse_geocoding_settings": "রিভার্স জিওকোডিং সেটিংস (Reverse Geocoding Settings)", + "map_settings": "মানচিত্র (Map)", + "map_settings_description": "মানচিত্রের সেটিংস পরিচালনা করুন (Manage map settings)", + "map_style_description": "একটি style.json ম্যাপ থিমের URL (URL to a style.json map theme)", + "memory_cleanup_job": "মেমরি ক্লিনআপ (Memory cleanup)", + "memory_generate_job": "স্মৃতি তৈরি করা(Memory generation)", + "metadata_extraction_job": "মেটাডেটা এক্সট্র্যাক্ট করুন (Extract metadata)", + "metadata_extraction_job_description": "প্রতিটি অ্যাসেট (Asset) থেকে মেটাডেটা তথ্য এক্সট্র্যাক্ট করুন, যেমন: জিপিএস (GPS), চেহারা (faces) এবং রেজোলিউশন (resolution)।", + "metadata_faces_import_setting": "ফেস ইম্পোর্ট সক্রিয় করুন (Enable face import)", + "metadata_faces_import_setting_description": "ছবির EXIF ডেটা এবং সাইডকার (sidecar) ফাইল থেকে চেহারা (faces) ইম্পোর্ট করুন।", + "metadata_settings": "মেটাডেটা সেটিংস (Metadata Settings)", + "metadata_settings_description": "মেটাডেটা সেটিংস পরিচালনা করুন (Manage metadata settings)", + "migration_job": "মাইগ্রেশন (Migration)", + "migration_job_description": "অ্যাসেট এবং ফেস থাম্বনেইলগুলোকে সর্বশেষ ফোল্ডার স্ট্রাকচারে মাইগ্রেট করুন। (Migrate thumbnails for assets and faces to the latest folder structure)", + "nightly_tasks_database_cleanup_setting": "ডেটাবেস ক্লিনআপ টাস্কসমূহ (Database cleanup tasks)", + "nightly_tasks_database_cleanup_setting_description": "ডেটাবেস থেকে পুরোনো এবং মেয়াদোত্তীর্ণ ডেটা মুছে ফেলুন", + "nightly_tasks_generate_memories_setting": "মেমোরিজ তৈরি করুন (Generate memories)", + "nightly_tasks_generate_memories_setting_description": "অ্যাসেটগুলো থেকে নতুন মেমোরিজ তৈরি করুন", + "nightly_tasks_missing_thumbnails_setting": "হারিয়ে যাওয়া থাম্বনেইলগুলো তৈরি করুন", + "nightly_tasks_missing_thumbnails_setting_description": "থাম্বনেইল নেই এমন ফাইলগুলোকে কিউতে (Queue) যোগ করুন", + "nightly_tasks_settings": "নাইটলি টাস্ক সেটিংস (Nightly Tasks Settings)", + "nightly_tasks_settings_description": "নাইটলি টাস্ক পরিচালনা করুন (Manage nightly tasks)", + "nightly_tasks_start_time_setting": "শুরু করার সময় (Start time)", + "nightly_tasks_start_time_setting_description": "সার্ভার যখন নাইটলি টাস্ক (nightly tasks) চালানো শুরু করে সেই সময়", + "nightly_tasks_sync_quota_usage_setting": "কোটা ব্যবহারের তথ্য সিঙ্ক করুন (Sync quota usage)", + "nightly_tasks_sync_quota_usage_setting_description": "বর্তমান ব্যবহারের ওপর ভিত্তি করে ব্যবহারকারীর স্টোরেজ কোটা আপডেট করুন।", + "no_paths_added": "কোনো পাথ যোগ করা হয়নি (No paths added)", + "no_pattern_added": "কোনো প্যাটার্ন যোগ করা হয়নি (No pattern added)", + "note_apply_storage_label_previous_assets": "দ্রষ্টব্য: পূর্বে আপলোড করা অ্যাসেটগুলোতে স্টোরেজ লেবেল (Storage Label) প্রয়োগ করতে নিচের কমান্ডটি রান করুন—", + "note_cannot_be_changed_later": "সতর্কবার্তা: এটি পরবর্তীতে পরিবর্তন করা যাবে না!", + "notification_email_from_address": "প্রেরকের ঠিকানা (From address)", + "notification_email_from_address_description": "প্রেরকের ইমেল ঠিকানা, উদাহরণস্বরূপ: \"Immich Photo Server noreply@example.com\"। নিশ্চিত করুন যে আপনি এমন একটি ঠিকানা ব্যবহার করছেন যা থেকে ইমেল পাঠানোর অনুমতি আপনার আছে।", + "notification_email_host_description": "ইমেল সার্ভারের হোস্ট (যেমন: smtp.immich.app)", + "notification_email_ignore_certificate_errors": "সার্টিফিকেট ত্রুটিগুলো উপেক্ষা করুন (Ignore certificate errors)", + "notification_email_ignore_certificate_errors_description": "TLS সার্টিফিকেট ভ্যালিডেশন ত্রুটিগুলো উপেক্ষা করুন (প্রস্তাবিত নয়)", + "notification_email_password_description": "ইমেল সার্ভারে অথেন্টিকেশন বা সত্যতা যাচাইয়ের জন্য ব্যবহৃত পাসওয়ার্ড", + "notification_email_port_description": "ইমেল সার্ভারের পোর্ট (যেমন: ২৫, ৪৬৫, অথবা ৫৮৭)", + "notification_email_secure": "SMTPS (স্মার্ট মেইল ট্রান্সফার প্রোটোকল সিকিউর)", + "notification_email_secure_description": "SMTPS (SMTP over TLS) ব্যবহার করুন", + "notification_email_sent_test_email_button": "টেস্ট ইমেল পাঠান এবং সেভ করুন", + "oauth_enable_description": "OAuth-এর মাধ্যমে লগইন করুন", + "oauth_mobile_redirect_uri": "মোবাইল রিডাইরেক্ট ইউআরআই (URI)", + "oauth_mobile_redirect_uri_override": "মোবাইল রিডাইরেক্ট ইউআরআই (URI) ওভাররাইড", + "oauth_mobile_redirect_uri_override_description": "যখন OAuth প্রোভাইডার মোবাইল ইউআরআই (URI) অনুমতি দেয় না, যেমন ''{callback}'', তখন এটি সক্রিয় করুন।", + "oauth_role_claim": "রোল ক্লেইম (Role Claim)", + "oauth_role_claim_description": "এই ক্লেইমটির উপস্থিতির ওপর ভিত্তি করে স্বয়ংক্রিয়ভাবে অ্যাডমিন অ্যাক্সেস প্রদান করুন। ক্লেইমটিতে 'user' অথবা 'admin' যেকোনো একটি থাকতে পারে।", + "oauth_settings": "OAuth", + "oauth_settings_description": "OAuth লগইন সেটিংস ম্যানেজ করুন", + "oauth_settings_more_details": "এই ফিচারের ব্যাপারে আরও বিস্তারিত জানতে, ডকুমেন্টস দেখুন।", + "oauth_storage_label_claim": "স্টোরেজ লেবেল ক্লেইম (Storage label claim)", + "oauth_storage_label_claim_description": "এই ক্লেইম-এর ভ্যালু অনুযায়ী ব্যবহারকারীর স্টোরেজ লেবেল স্বয়ংক্রিয়ভাবে সেট করুন।", + "oauth_storage_quota_claim": "স্টোরেজ কোটা ক্লেইম (Storage quota claim)", + "oauth_storage_quota_claim_description": "এই ক্লেইম-এর ভ্যালু অনুযায়ী ব্যবহারকারীর স্টোরেজ কোটা স্বয়ংক্রিয়ভাবে সেট করুন।", + "oauth_storage_quota_default": "ডিফল্ট স্টোরেজ কোটা (GiB)", + "oauth_storage_quota_default_description": "ক্লেইম না দেওয়া থাকলে যে স্টোরেজ কোটা (GiB-তে) ব্যবহার করা হবে।", + "oauth_timeout": "রিকোয়েস্ট টাইম-আউট (Request Timeout)", + "oauth_timeout_description": "মিলিসেকেন্ডে রিকোয়েস্টের টাইম-আউট (Timeout for requests in milliseconds)", + "ocr_job_description": "ছবি থেকে টেক্সট শনাক্ত করতে মেশিন লার্নিং ব্যবহার করুন", + "password_enable_description": "ইমেল এবং পাসওয়ার্ড দিয়ে লগইন করুন", + "password_settings": "পাসওয়ার্ড লগইন (Password Login)", + "password_settings_description": "পাসওয়ার্ড লগইন সেটিংস ম্যানেজ করুন", + "paths_validated_successfully": "সবগুলো পাথ (path) সফলভাবে যাচাই করা হয়েছে", + "person_cleanup_job": "পারসন ক্লিনআপ (Person Cleanup)", + "queue_details": "কিউ ডিটেইলস (Queue Details)", + "queues": "জব কিউ (Job Queues)", + "queues_page_description": "অ্যাডমিন জব কিউ (Job Queues) পেজ", + "quota_size_gib": "কোটা সাইজ (GiB)", + "refreshing_all_libraries": "সবগুলো লাইব্রেরি রিফ্রেশ করা হচ্ছে", + "registration": "অ্যাডমিন রেজিস্ট্রেশন (Admin Registration)", + "registration_description": "যেহেতু আপনি এই সিস্টেমের প্রথম ব্যবহারকারী, তাই আপনাকে অ্যাডমিন (Admin) হিসেবে নিযুক্ত করা হবে। আপনি সমস্ত প্রশাসনিক কাজের জন্য দায়ী থাকবেন এবং পরবর্তী ব্যবহারকারীরা আপনার মাধ্যমেই তৈরি হবে।", + "remove_failed_jobs": "ব্যর্থ হওয়া কাজগুলো মুছে ফেলুন (Remove failed jobs)", + "require_password_change_on_login": "প্রথমবার লগইন করার সময় ব্যবহারকারীর পাসওয়ার্ড পরিবর্তন করা বাধ্যতামূলক করুন", + "reset_settings_to_default": "সেটিংস রিসেট করে ডিফল্ট অবস্থায় ফিরিয়ে আনুন (Reset settings to default)", + "reset_settings_to_recent_saved": "সম্প্রতি সেভ করা সেটিংসে রিসেট করুন (Reset settings to the recent saved settings)", + "scanning_library": "লাইব্রেরি স্ক্যান করা হচ্ছে (Scanning library)", + "search_jobs": "জব সার্চ করুন…", + "send_welcome_email": "স্বাগত ইমেল পাঠান", + "server_external_domain_settings": "এক্সটার্নাল ডোমেইন (External Domain)", + "server_external_domain_settings_description": "পাবলিক শেয়ারিং লিঙ্কের জন্য ডোমেইন (http(s):// সহ)", + "server_public_users": "পাবলিক ইউজার (Public Users)", + "server_public_users_description": "শেয়ার করা অ্যালবামে কোনো ব্যবহারকারীকে যোগ করার সময় সমস্ত ব্যবহারকারীর (নাম এবং ইমেল) তালিকা দেখানো হয়। এটি নিষ্ক্রিয় (Disabled) করা হলে, ব্যবহারকারীর তালিকা শুধুমাত্র অ্যাডমিনদের জন্য উপলব্ধ হবে।", + "server_settings": "সার্ভার সেটিংস (Server Settings)", + "server_settings_description": "সার্ভার সেটিংস ম্যানেজ করুন (Manage server settings)", + "server_stats_page_description": "অ্যাডমিন সার্ভার স্ট্যাটিস্টিকস (Server Statistics) পেজ", + "server_welcome_message": "স্বাগত বার্তা (Welcome message)", + "server_welcome_message_description": "লগইন পেজে প্রদর্শিত একটি বার্তা।", + "settings_page_description": "অ্যাডমিন সেটিংস পেজ", + "sidecar_job": "সাইডকার মেটাডেটা (Sidecar Metadata)", + "sidecar_job_description": "ফাইলসিস্টেম থেকে সাইডকার মেটাডেটা অনুসন্ধান বা সিঙ্ক্রোনাইজ করুন", + "slideshow_duration_description": "প্রতিটি ছবি দেখানোর সময়কাল (সেকেন্ডে)", + "smart_search_job_description": "স্মার্ট সার্চের সুবিধার্থে অ্যাসেটগুলোর ওপর মেশিন লার্নিং পরিচালনা করুন", + "storage_template_date_time_description": "অ্যাসেট তৈরির সময়কাল (Timestamp) তারিখ ও সময়ের তথ্যের জন্য ব্যবহৃত হয়", + "storage_template_date_time_sample": "নমুনা সময় {date}", + "storage_template_enable_description": "স্টোরেজ টেমপ্লেট ইঞ্জিন সক্রিয় করুন", + "storage_template_hash_verification_enabled": "হ্যাশ ভেরিফিকেশন (Hash Verification) সক্রিয় করা হয়েছে", + "storage_template_hash_verification_enabled_description": "হ্যাশ ভেরিফিকেশন (Hash Verification) সক্রিয় করে; এর প্রভাব সম্পর্কে নিশ্চিত না হয়ে এটি নিষ্ক্রিয় করবেন না", + "storage_template_migration": "স্টোরেজ টেমপ্লেট মাইগ্রেশন (Storage Template Migration)", + "storage_template_migration_description": "পূর্বে আপলোড করা অ্যাসেটগুলোতে বর্তমান {template} প্রয়োগ করুন", + "storage_template_migration_info": "স্টোরেজ টেমপ্লেটটি সমস্ত এক্সটেনশনকে ছোট হাতের অক্ষরে (lowercase) রূপান্তর করবে। টেমপ্লেটের পরিবর্তনগুলো কেবল নতুন অ্যাসেটগুলোর ক্ষেত্রে প্রযোজ্য হবে। পূর্বে আপলোড করা অ্যাসেটগুলোতে এই টেমপ্লেটটি ভূতাপেক্ষভাবে (retroactively) প্রয়োগ করতে {job} রান করুন।", + "storage_template_migration_job": "স্টোরেজ টেমপ্লেট মাইগ্রেশন জব", + "storage_template_more_details": "এই ফিচারটি সম্পর্কে আরও বিস্তারিত জানতে, Storage Template এবং এর প্রভাবগুলো (implications) দেখুন।", + "storage_template_onboarding_description_v2": "এটি সক্রিয় থাকলে, ফিচারটি ব্যবহারকারীর নির্ধারিত টেমপ্লেট অনুযায়ী ফাইলগুলোকে স্বয়ংক্রিয়ভাবে অর্গানাইজ (Auto-organize) করবে। আরও তথ্যের জন্য অনুগ্রহ করে ডকুমেন্টেশন দেখুন।", + "storage_template_path_length": "আনুমানিক পাথ লেন্থ লিমিট (Path length limit): {length, number}/{limit, number}", + "storage_template_settings": "স্টোরেজ টেমপ্লেট (Storage Template)", + "storage_template_settings_description": "আপলোড করা অ্যাসেটের ফোল্ডার স্ট্রাকচার এবং ফাইল নেম ম্যানেজ করুন", + "storage_template_user_label": "{label} হলো ব্যবহারকারীর স্টোরেজ লেবেল (Storage Label)", + "theme_settings_description": "ইমিচ (Immich) ওয়েব ইন্টারফেসের কাস্টমাইজেশন ম্যানেজ করুন", + "thumbnail_generation_job": "থাম্বনেইল তৈরি করুন (Generate Thumbnails)", + "thumbnail_generation_job_description": "প্রতিটি অ্যাসেটের জন্য বড়, ছোট এবং ব্লার (অস্পষ্ট) থাম্বনেইল তৈরি করুন, সেই সাথে প্রতিটি ব্যক্তির জন্যও থাম্বনেইল তৈরি করুন।" + }, + "yes": "হ্যাঁ", + "you_dont_have_any_shared_links": "আপনার কোনো শেয়ার করা লিঙ্ক নেই (You don't have any shared links)", + "your_wifi_name": "আপনার ওয়াই-ফাই এর নাম (Your Wi-Fi name)", + "zero_to_clear_rating": "অ্যাসেট রেটিং মুছে ফেলতে ০ চাপুন", + "zoom_image": "ছবি জুম করুন (Zoom Image)", + "zoom_to_bounds": "বাউন্ডস অনুযায়ী জুম করুন (Zoom to bounds)" } diff --git a/i18n/ca.json b/i18n/ca.json index ad02cb3cfa..737bb5bce8 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Disseny", "asset_list_settings_subtitle": "Configuració del disseny de la graella de fotos", "asset_list_settings_title": "Graella de fotos", + "asset_not_found_on_device_android": "No s'ha trobat l'actiu al dispositiu", + "asset_not_found_on_device_ios": "No s'ha trobat l'element al dispositiu. Si utilitzes l'iCloud, pot ser que no s'hi pugui accedir perquè el fitxer guardat a l'iCloud és corrupte", + "asset_not_found_on_icloud": "No s'ha trobat l'element a l'iCloud. Pot ser que no s'hi pugui accedir perquè el fitxer guardat a l'iCloud és corrupte", "asset_offline": "Element fora de línia", "asset_offline_description": "Aquest recurs extern ja no es troba al disc. Poseu-vos en contacte amb el vostre administrador d'Immich per obtenir ajuda.", "asset_restored_successfully": "Element recuperat correctament", @@ -1192,7 +1195,6 @@ "features": "Característiques", "features_in_development": "Funcions en desenvolupament", "features_setting_description": "Administrar les funcions de l'aplicació", - "file_name": "Nom de l'arxiu: {file_name}", "file_name_or_extension": "Nom de l'arxiu o extensió", "file_size": "Mida del fitxer", "filename": "Nom del fitxer", @@ -2295,6 +2297,7 @@ "upload_details": "Detalls de la Pujada", "upload_dialog_info": "Vols fer còpia de seguretat dels elements seleccionats al servidor?", "upload_dialog_title": "Puja elements", + "upload_error_with_count": "Error en la càrrega de {count, plural, one {# actiu} other {# actius}}", "upload_errors": "Càrrega completada amb {count, plural, one {# error} other {# errors}}, actualitzeu la pàgina per veure els nous elements carregats.", "upload_finished": "Pujada finalitzada", "upload_progress": "Restant {remaining, number} - Processat {processed, number}/{total, number}", diff --git a/i18n/cs.json b/i18n/cs.json index 3a916f1484..f72b9b164c 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -1195,7 +1195,6 @@ "features": "Funkce", "features_in_development": "Funkce ve vývoji", "features_setting_description": "Správa funkcí aplikace", - "file_name": "Název souboru: {file_name}", "file_name_or_extension": "Název nebo přípona souboru", "file_size": "Velikost souboru", "filename": "Název souboru", diff --git a/i18n/da.json b/i18n/da.json index d221e68907..7f2b77dc28 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -1189,7 +1189,6 @@ "features": "Funktioner", "features_in_development": "Funktioner under udvikling", "features_setting_description": "Administrer app-funktioner", - "file_name": "Filnavn: {file_name}", "file_name_or_extension": "Filnavn eller filtype", "file_size": "Fil størrelse", "filename": "Filnavn", diff --git a/i18n/de.json b/i18n/de.json index b900e52513..8959e20831 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -526,8 +526,8 @@ "allowed": "Erlaubt", "alt_text_qr_code": "QR-Code Bild", "always_keep": "Immer behalten", - "always_keep_photos_hint": "Speicher freigeben wird alle Fotos auf dem Gerät behalten", - "always_keep_videos_hint": "Speicher freigeben wird alle Videos auf dem Gerät behalten", + "always_keep_photos_hint": "Speicherfreigabe wird alle Fotos auf dem Gerät behalten.", + "always_keep_videos_hint": "Speicherfreigabe wird alle Videos auf dem Gerät behalten.", "anti_clockwise": "Gegen den Uhrzeigersinn", "api_key": "API-Schlüssel", "api_key_description": "Dieser Wert wird nur einmal angezeigt. Bitte kopiere ihn, bevor du das Fenster schließt.", @@ -537,7 +537,7 @@ "app_bar_signout_dialog_content": "Bist du dir sicher, dass du dich abmelden möchtest?", "app_bar_signout_dialog_ok": "Ja", "app_bar_signout_dialog_title": "Abmelden", - "app_download_links": "App Download Links", + "app_download_links": "App Download-Links", "app_settings": "App-Einstellungen", "app_stores": "App Stores", "app_update_available": "App Update verfügbar", @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Einstellungen für das Fotogitter-Layout", "asset_list_settings_title": "Fotogitter", + "asset_not_found_on_device_android": "Datei auf Gerät nicht gefunden", + "asset_not_found_on_device_ios": "Datei auf Gerät nicht gefunden. Wenn Du iCloud verwendest, kann die Datei möglicherweise nicht auffindbar sein aufgrund schlechter Dateispeicherung von iCloud", + "asset_not_found_on_icloud": "Datei in iCloud nicht gefunden. Die Datei kann möglicherweise nicht auffindbar sein aufgrund schlechter Dateispeicherung in iCloud", "asset_offline": "Datei offline", "asset_offline_description": "Diese externe Datei ist nicht mehr auf dem Datenträger vorhanden. Bitte wende dich an deinen Immich-Administrator, um Hilfe zu erhalten.", "asset_restored_successfully": "Datei erfolgreich wiederhergestellt", @@ -607,14 +610,14 @@ "assets_were_part_of_album_count": "{count, plural, one {# Datei ist} other {# Dateien sind}} bereits im Album vorhanden", "assets_were_part_of_albums_count": "{count, plural, one {Datei war} other {Dateien waren}} bereits in den Alben", "authorized_devices": "Verwendete Geräte", - "automatic_endpoint_switching_subtitle": "Verbinden Sie sich lokal über ein bestimmtes WLAN, wenn es verfügbar ist, und verwenden Sie andere Verbindungsmöglichkeiten anderswo", + "automatic_endpoint_switching_subtitle": "Verbinden Sie sich lokal über ein bestimmtes WiFi, wenn es verfügbar ist, und verwenden Sie andere Verbindungsmöglichkeiten", "automatic_endpoint_switching_title": "Automatische URL-Umschaltung", "autoplay_slideshow": "Automatische Diashow", "back": "Zurück", "back_close_deselect": "Zurück, Schließen oder Abwählen", "background_backup_running_error": "Sicherung läuft im Hintergrund. Manuelle Sicherung kann nicht gestartet werden", "background_location_permission": "Hintergrund Standortfreigabe", - "background_location_permission_content": "Um im Hintergrund zwischen den Netzwerken wechseln zu können, muss Immich *immer* Zugriff auf den genauen Standort haben, damit die App den Namen des WLAN-Netzwerks ermitteln kann", + "background_location_permission_content": "Um im Hintergrund zwischen den Netzwerken wechseln zu können, muss Immich *immer* Zugriff auf den genauen Standort haben, damit die App den Namen des WiFi-Netzwerks ermitteln kann", "background_options": "Hintergrund Optionen", "backup": "Sicherung", "backup_album_selection_page_albums_device": "Alben auf dem Gerät ({count})", @@ -649,7 +652,7 @@ "backup_controller_page_background_is_on": "Automatische Sicherung im Hintergrund ist aktiviert", "backup_controller_page_background_turn_off": "Hintergrundservice ausschalten", "backup_controller_page_background_turn_on": "Hintergrundservice einschalten", - "backup_controller_page_background_wifi": "Nur im WLAN", + "backup_controller_page_background_wifi": "Nur im WiFi", "backup_controller_page_backup": "Sicherung", "backup_controller_page_backup_selected": "Ausgewählt: ", "backup_controller_page_backup_sub": "Gesicherte Fotos und Videos", @@ -751,7 +754,7 @@ "charging_requirement_mobile_backup": "Backup im Hintergrund erfordert Aufladen des Geräts", "check_corrupt_asset_backup": "Auf beschädigte Asset-Backups überprüfen", "check_corrupt_asset_backup_button": "Überprüfung durchführen", - "check_corrupt_asset_backup_description": "Führe diese Prüfung nur mit aktivierten WLAN durch, nachdem alle Dateien gesichert worden sind. Dieser Vorgang kann ein paar Minuten dauern.", + "check_corrupt_asset_backup_description": "Führe diese Prüfung nur mit aktivierten WiFi durch, nachdem alle Dateien gesichert worden sind. Dieser Vorgang kann ein paar Minuten dauern.", "check_logs": "Logs prüfen", "checksum": "Prüfsumme", "choose_matching_people_to_merge": "Wähle passende Personen zum Zusammenführen", @@ -988,12 +991,12 @@ "edit_title": "Titel bearbeiten", "edit_user": "Nutzer bearbeiten", "edit_workflow": "Workflow bearbeiten", - "editor": "Bearbeiter", + "editor": "Bearbeiten", "editor_close_without_save_prompt": "Die Änderungen werden nicht gespeichert", "editor_close_without_save_title": "Editor schließen?", "editor_confirm_reset_all_changes": "Alle Änderungen zurücksetzen?", - "editor_flip_horizontal": "horizontal spiegeln", - "editor_flip_vertical": "vertikal spiegeln", + "editor_flip_horizontal": "Horizontal spiegeln", + "editor_flip_vertical": "Vertikal spiegeln", "editor_orientation": "Ausrichtung", "editor_reset_all_changes": "Änderungen zurücksetzen", "editor_rotate_left": "Um 90° gegen den Uhrzeigersinn drehen", @@ -1009,7 +1012,7 @@ "enabled": "Aktiviert", "end_date": "Enddatum", "enqueued": "Eingereiht", - "enter_wifi_name": "WLAN-Name eingeben", + "enter_wifi_name": "WiFi-Name eingeben", "enter_your_pin_code": "PIN-Code eingeben", "enter_your_pin_code_subtitle": "Gib deinen PIN-Code ein, um auf den gesperrten Ordner zuzugreifen", "error": "Fehler", @@ -1192,7 +1195,6 @@ "features": "Funktionen", "features_in_development": "Feature in Entwicklung", "features_setting_description": "Funktionen der App verwalten", - "file_name": "Dateiname: {file_name}", "file_name_or_extension": "Dateiname oder -erweiterung", "file_size": "Dateigröße", "filename": "Dateiname", @@ -1221,7 +1223,7 @@ "geolocation_instruction_location": "Klicke auf eine Datei mit GPS Koordinaten um diesen Standort zu verwenden oder wähle einen Standort direkt auf der Karte", "get_help": "Hilfe erhalten", "get_people_error": "Fehler beim Laden der Personen", - "get_wifiname_error": "WLAN-Name konnte nicht ermittelt werden. Vergewissere dich, dass die erforderlichen Berechtigungen erteilt wurden und du mit einem WLAN-Netzwerk verbunden bist", + "get_wifiname_error": "WiFi-Name konnte nicht ermittelt werden. Vergewissere dich, dass die erforderlichen Berechtigungen erteilt wurden und du mit einem WiFi-Netzwerk verbunden bist", "getting_started": "Erste Schritte", "go_back": "Zurück", "go_to_folder": "Gehe zu Ordner", @@ -1385,7 +1387,7 @@ "local_network_sheet_info": "Die App stellt über diese URL eine Verbindung zum Server her, wenn sie das angegebene WLAN-Netzwerk verwendet", "location": "Standort", "location_permission": "Standort Genehmigung", - "location_permission_content": "Um die automatische Umschaltfunktion nutzen zu können, benötigt Immich genaue Standortberechtigung, damit es den Namen des aktuellen WLAN-Netzwerks ermitteln kann", + "location_permission_content": "Um die automatische Umschaltfunktion nutzen zu können, benötigt Immich genaue Standortberechtigung, damit es den Namen des aktuellen WiFi-Netzwerks ermitteln kann", "location_picker_choose_on_map": "Auf der Karte auswählen", "location_picker_latitude_error": "Gültigen Breitengrad eingeben", "location_picker_latitude_hint": "Breitengrad eingeben", @@ -2200,7 +2202,7 @@ "theme_setting_asset_list_storage_indicator_title": "Fortschrittsbalken der Sicherung auf dem Vorschaubild", "theme_setting_asset_list_tiles_per_row_title": "Anzahl der Elemente pro Reihe ({count})", "theme_setting_colorful_interface_subtitle": "Primärfarbe auf App-Hintergrund anwenden.", - "theme_setting_colorful_interface_title": "Farbige UI-Oberfläche", + "theme_setting_colorful_interface_title": "Farbige Benutzeroberfläche", "theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters", "theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters", "theme_setting_primary_color_subtitle": "Farbauswahl für primäre Aktionen und Akzente.", @@ -2295,6 +2297,7 @@ "upload_details": "Upload Details", "upload_dialog_info": "Willst du die ausgewählten Elemente auf dem Server sichern?", "upload_dialog_title": "Element hochladen", + "upload_error_with_count": "Uploadfehler für {count, plural, one {# asset} other {# assets}}", "upload_errors": "Hochladen mit {count, plural, one {# Fehler} other {# Fehlern}} abgeschlossen, aktualisiere die Seite, um neu hochgeladene Dateien zu sehen.", "upload_finished": "Upload fertig", "upload_progress": "{remaining, number} verbleibend - {processed, number}/{total, number} verarbeitet", @@ -2372,7 +2375,7 @@ "welcome": "Willkommen", "welcome_to_immich": "Willkommen bei Immich", "width": "Breite", - "wifi_name": "WLAN-Name", + "wifi_name": "WiFi-Name", "workflow_delete_prompt": "Bist du sicher, dass du diesen Workflow löschen willst?", "workflow_deleted": "Workflow gelöscht", "workflow_description": "Workflow-Beschreibung", @@ -2391,7 +2394,7 @@ "years_ago": "Vor {years, plural, one {einem Jahr} other {# Jahren}}", "yes": "Ja", "you_dont_have_any_shared_links": "Du hast keine geteilten Links", - "your_wifi_name": "Dein WLAN-Name", + "your_wifi_name": "Dein WiFi-Name", "zero_to_clear_rating": "drücke 0 um die Dateibewertung zurückzusetzen", "zoom_image": "Bild vergrößern", "zoom_to_bounds": "Auf Grenzen zoomen" diff --git a/i18n/el.json b/i18n/el.json index 9df82ce4ad..072e283a72 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -1181,7 +1181,6 @@ "features": "Χαρακτηριστικά", "features_in_development": "Λειτουργίες υπό Ανάπτυξη", "features_setting_description": "Διαχειριστείτε τα χαρακτηριστικά της εφαρμογής", - "file_name": "Όνομα αρχείου: {file_name}", "file_name_or_extension": "Όνομα αρχείου ή επέκταση", "file_size": "Μέγεθος αρχείου", "filename": "Ονομασία αρχείου", diff --git a/i18n/en.json b/i18n/en.json index a435c5986e..2d3d3680f8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -782,6 +782,8 @@ "client_cert_import": "Import", "client_cert_import_success_msg": "Client certificate is imported", "client_cert_invalid_msg": "Invalid certificate file or wrong password", + "client_cert_password_message": "Enter the password for this certificate", + "client_cert_password_title": "Certificate Password", "client_cert_remove_msg": "Client certificate is removed", "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login", "client_cert_title": "SSL client certificate [EXPERIMENTAL]", @@ -1195,8 +1197,9 @@ "features": "Features", "features_in_development": "Features in Development", "features_setting_description": "Manage the app features", - "file_name": "File name: {file_name}", "file_name_or_extension": "File name or extension", + "file_name_text": "File name", + "file_name_with_value": "File name: {file_name}", "file_size": "File size", "filename": "Filename", "filetype": "Filetype", diff --git a/i18n/eo.json b/i18n/eo.json index 9e8c8f8510..b77577ea15 100644 --- a/i18n/eo.json +++ b/i18n/eo.json @@ -104,6 +104,8 @@ "image_preview_description": "Mez-granda bildo, sen metadatumoj, uzata por montri unuopan bildon, kaj por maŝin-lernado", "image_preview_quality_description": "Kvalito de antaŭvido, inter 1 kaj 100. Pli alta numero indikas pli altan kvaliton, sed ankaŭ kreas pli grandajn dosierojn, kiuj povas malrapidigi uzadon de la apo. Tro malalta numero povas noci la maŝin-lernadon.", "image_preview_title": "Agordoj pri antaŭvidoj", + "image_progressive": "Poiome", + "image_progressive_description": "Kodigi JPEG-bildojn por poioma vidigo dum ŝargado. Tio ŝanĝas nenion por WebP-bildoj.", "image_quality": "Kvalito", "image_resolution": "Distingivo", "image_resolution_description": "Alta distingivo povas konservi pli da detaloj en bildoj sed postulas pli da tempo por trakti, donas pli grandajn dosierojn por stokie, kaj povas malrapidigi uzadon de la apo.", @@ -270,7 +272,7 @@ "oauth_auto_register": "Registri aŭtomate", "oauth_auto_register_description": "Aŭtomate registri novajn uzantojn tuj post ensaluto per OAuth", "oauth_button_text": "Teksto de butono", - "oauth_client_secret_description": "Bezonata kaze ke la provizanto de OAuth ne subtenas PKCE (Proof Key for Code Exchange)", + "oauth_client_secret_description": "Bezonata por privata kliento, aŭ se PKCE (Proof Key for Code Exchange) ne estas subtenata de publika kliento.", "oauth_enable_description": "Ensaluti per OAuth", "oauth_mobile_redirect_uri": "Resenda URI por poŝ-aparatoj", "oauth_mobile_redirect_uri_override": "Insisti pri resenda URI por poŝ-aparatoj", @@ -354,8 +356,8 @@ "theme_settings_description": "Administri tajloradon de la reta interfaco de Immich", "thumbnail_generation_job": "Generi bildetojn", "thumbnail_generation_job_description": "Kreas grandan, malgrandan, kaj malklaran bildetojn por ĉiu elemento, kune kun bildeto por ĉiu homo", - "transcoding_acceleration_api": "API de akcelado", - "transcoding_acceleration_api_description": "La API, kiu interagos kun via aparato por akceli la transkodadon. Tiu ĉi agordaĵo indikas preferon – kaze de malsukceso, ĝi retropaŝas al softvara transkodado. VP9 povas funkcii aŭ ne, depende de viaj aparatoj.", + "transcoding_acceleration_api": "API de pliradidigo", + "transcoding_acceleration_api_description": "La API, kiu interagos kun via aparato por plirapidigi la transkodadon. Tiu ĉi agordaĵo indikas preferon – kaze de malsukceso, ĝi retropaŝas al softvara transkodado. VP9 povas funkcii aŭ ne, depende de viaj aparatoj.", "transcoding_acceleration_nvenc": "NVENC (postulas GPU de NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (postulas ĉefprocesoron Intel de minimume 7-a generacio)", "transcoding_acceleration_rkmpp": "RKMPP (nur por SOC-oj de Rockchip)", @@ -369,6 +371,29 @@ "transcoding_advanced_options_description": "Agordoj, kiujn plej multaj uzantoj ne bezonas ŝanĝi", "transcoding_audio_codec": "Sonkodeko", "transcoding_audio_codec_description": "Opus estas la plej altkvalita elekto, sed ĝi ne kongruas kun malnovaj aparatoj kaj softvaroj.", + "transcoding_bitrate_description": "Videoj kun bitrapido pli alta ol maksimumo, aŭ ne en akceptita formato", + "transcoding_codecs_learn_more": "Per lerni pli pri la terminaro uzata ĉi tie, legu la dokumentaron de FFmpeg pri kodeko H.264, kodeko HEVC kaj kodeko VP9.", + "transcoding_constant_quality_mode": "Reĝimo de konstanta kvalito", + "transcoding_constant_quality_mode_description": "ICQ estas pli bona ol CQP, sed kelkaj aparatoj de plirapidigo ne subtenas ĝin. Ŝalti tion ĉi privilegiigas la elektitan reĝimon dum uzo de kodado bazita sur kvalito. Ignorita de NVENC ĉar ĝi ne subtenas ICQ.", + "transcoding_constant_rate_factor": "Konstanta rapida faktoro (-crf)", + "transcoding_constant_rate_factor_description": "Nivelo de video-kvalito. Tipaj valoroj estas 23 por H.264, 28 por HEVC, 31 por VP9, kaj 35 por AV1. Pli malalta cifero indikas pli altan kvaliton, sed kreas pli pezajn dosierojn.", + "transcoding_disabled_description": "Ne transkodigi videojn. Tio povas perturbi vidigon en kelkaj klientoj", + "transcoding_encoding_options": "Agordoj de kodigo", + "transcoding_encoding_options_description": "Administri agordojn pri kodekoj, distingivo, kvalito, ktp. por la kodigitaj videoj", + "transcoding_hardware_acceleration": "Aparata plirapidigo", + "transcoding_hardware_acceleration_description": "Eksperimenta: pli rapida kodado, sed eble kun malpli bona kvalito je sama bitrapido", + "transcoding_hardware_decoding": "Aparata malkodado", + "transcoding_hardware_decoding_setting_description": "Ŝaltas tutvojan plirapidigon anstataŭ nur pliradidan kodadon. Povus ne funkcii por kelkaj videoj.", + "transcoding_max_b_frames": "Makimuma nombro de B-kadroj", + "transcoding_max_b_frames_description": "Pli alta valoro indikas pli efikan densigon, sed malpli rapidan kodadon. Eble ne funkcios kun pli malnova aparata plirapidigo. Valoro de 0 malŝaltas B-kadrojn. Valoro de -1 indikas aŭtomate elektitan valoron.", + "transcoding_max_bitrate": "Maksimuma bitrapido", + "transcoding_max_bitrate_description": "Agordi maksimuman bitrapidon rezultas je dosieroj kun pli antaŭvidebla grandeco, kun nur malgranda perdo de kvalito. Por 720p, tipaj valoroj estas 2600 kbit/s por VP9 aŭ HEVC, aŭ 4500 kbit/s por H.264. Valoro de 0 indikas 'malŝaltita'. Defaŭlta unuo estas k (t.e. kbit/s), do '5000', '5000k' kaj '5M' estas ekvivalentaj.", + "transcoding_max_keyframe_interval": "Maksimuma intervalo inter ĉefaj kadroj", + "transcoding_max_keyframe_interval_description": "Agordas la maksimuman distancon inter ĉefaj kadroj. Malaltaj valoroj malhelpas densigon, sed povas plibonigi kvaliton en scenoj kun rapidaj movoj. Valoro de 0 indikas aŭtomatan agordigon.", + "transcoding_optimal_description": "La videoj havas distingivon pli altan ol tiu celita, aŭ ne havas akcepteblan formaton", + "transcoding_policy": "Politiko de transkodado", + "transcoding_policy_description": "Kriterioj por indiki ĉu video estas transkodita aŭ ne", + "transcoding_preferred_hardware_device": "Preferita aparato", "transcoding_settings_description": "Administri transkodadon de videoj", "trash_settings_description": "Administri agordojn pri rubaĵoj", "user_settings_description": "Administri agordojn pri uzantoj" diff --git a/i18n/es.json b/i18n/es.json index c6d4106bd3..3f9163481d 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -5,7 +5,7 @@ "acknowledge": "Aceptar", "action": "Acción", "action_common_update": "Actualizar", - "action_description": "Un conjunto de acciones a realizar en los activos filtrados", + "action_description": "Un conjunto de acciones a realizar en los recursos filtrados", "actions": "Acciones", "active": "Activo", "active_count": "Activo: {count}", @@ -182,10 +182,10 @@ "machine_learning_ocr_min_recognition_score": "Puntuación mínima de reconocimiento", "machine_learning_ocr_min_score_recognition_description": "Puntuación mínima de confianza para que el texto detectado sea reconocido de 0 a 1. Los valores más bajos reconocerán más texto, pero pueden producir falsos positivos.", "machine_learning_ocr_model": "Modelo de OCR", - "machine_learning_ocr_model_description": "Los modelos del servidor son más precisos que los modelos para móviles móviles, pero tardan más en procesar y consumen más memoria.", + "machine_learning_ocr_model_description": "Los modelos del servidor son más precisos que los modelos móviles, pero tardan más en procesar y consumen más memoria.", "machine_learning_settings": "Configuración de aprendizaje automático", "machine_learning_settings_description": "Administrar funciones y configuraciones de aprendizaje automático", - "machine_learning_smart_search": "Busqueda inteligente", + "machine_learning_smart_search": "Búsqueda inteligente", "machine_learning_smart_search_description": "Busque imágenes semánticamente utilizando incrustaciones CLIP (Contrastive Language-Image Pre-Training)", "machine_learning_smart_search_enabled": "Habilitar búsqueda inteligente", "machine_learning_smart_search_enabled_description": "Al desactivarlo las imágenes no se procesarán para usar la búsqueda inteligente.", @@ -272,7 +272,7 @@ "oauth_auto_register": "Registro automático", "oauth_auto_register_description": "Registre automáticamente nuevos usuarios después de iniciar sesión con OAuth", "oauth_button_text": "Texto del botón", - "oauth_client_secret_description": "Requerido si PKCE (Prueba de clave para el intercambio de códigos) no es compatible con el proveedor OAuth", + "oauth_client_secret_description": "Requerido para clientes confidenciales, o si PKCE (Prueba de clave para el intercambio de códigos) no es compatible con clientes públicos.", "oauth_enable_description": "Iniciar sesión con OAuth", "oauth_mobile_redirect_uri": "URI de redireccionamiento móvil", "oauth_mobile_redirect_uri_override": "Sobreescribir URI de redirección móvil", @@ -354,7 +354,7 @@ "theme_custom_css_settings_description": "Las Hojas de Estilo (CSS) permiten personalizar el diseño de Immich.", "theme_settings": "Ajustes del tema", "theme_settings_description": "Gestionar la personalización de la interfaz web de Immich", - "thumbnail_generation_job": "Generar Miniaturas", + "thumbnail_generation_job": "Generar miniaturas", "thumbnail_generation_job_description": "Genere miniaturas grandes, pequeñas y borrosas para cada archivo, así como miniaturas para cada persona", "transcoding_acceleration_api": "API Aceleración", "transcoding_acceleration_api_description": "La API que interactuará con su dispositivo para acelerar la transcodificación. Esta configuración es el \"mejor esfuerzo\": recurrirá a la transcodificación del software en caso de error. VP9 puede funcionar o no dependiendo de su hardware.", @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Disposición", "asset_list_settings_subtitle": "Configuraciones del diseño de la cuadrícula de fotos", "asset_list_settings_title": "Cuadrícula de fotos", + "asset_not_found_on_device_android": "Activo no encontrado en el dispositivo", + "asset_not_found_on_device_ios": "No se encuentra el recurso en el dispositivo. Si usa iCloud, es posible que no pueda acceder al recurso debido a un archivo defectuoso almacenado en iCloud", + "asset_not_found_on_icloud": "No se ha encontrado el recurso en iCloud. Es posible que no se pueda acceder al recurso debido a un archivo defectuoso almacenado en iCloud", "asset_offline": "Archivos sin conexión", "asset_offline_description": "Este activo externo ya no se encuentra en el disco. Por favor, póngase en contacto con su administrador de Immich para obtener ayuda.", "asset_restored_successfully": "Elementos restaurados exitosamente", @@ -582,7 +585,7 @@ "asset_uploaded": "Subido", "asset_uploading": "Subiendo…", "asset_viewer_settings_subtitle": "Administra las configuraciones de tu visor de fotos", - "asset_viewer_settings_title": "Visor de Archivos", + "asset_viewer_settings_title": "Visor de archivos", "assets": "elementos", "assets_added_count": "{count, plural, one {# elemento añadido} other {# elementos añadidos}}", "assets_added_to_album_count": "{count, plural, one {# elemento añadido} other {# elementos añadidos}} al álbum", @@ -606,7 +609,7 @@ "assets_trashed_from_server": "{count} recurso(s) enviado(s) a la papelera desde el servidor de Immich", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} ya forma parte del álbum", "assets_were_part_of_albums_count": "{count, plural, one {El elemento ya es} other {Los elementos ya son}} parte de los álbumes", - "authorized_devices": "Dispositivos Autorizados", + "authorized_devices": "Dispositivos autorizados", "automatic_endpoint_switching_subtitle": "Conectarse localmente a través de la Wi-Fi designada cuando esté disponible y usar conexiones alternativas en otros lugares", "automatic_endpoint_switching_title": "Cambio automático de URL", "autoplay_slideshow": "Presentación con reproducción automática", @@ -616,7 +619,7 @@ "background_location_permission": "Permiso de ubicación en segundo plano", "background_location_permission_content": "Para poder cambiar de red mientras se ejecuta en segundo plano, Immich debe tener *siempre* acceso a la ubicación precisa para que la aplicación pueda leer el nombre de la red Wi-Fi", "background_options": "Opciones de segundo plano", - "backup": "Copia de Seguridad", + "backup": "Copia de seguridad", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({count})", "backup_album_selection_page_albums_tap": "Toque para incluir, doble toque para excluir", "backup_album_selection_page_assets_scatter": "Los elementos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.", @@ -1170,8 +1173,8 @@ "explorer": "Explorador", "export": "Exportar", "export_as_json": "Exportar a JSON", - "export_database": "Exportar Base de Datos", - "export_database_description": "Exportar la Base de Datos SQLite", + "export_database": "Exportar base de datos", + "export_database_description": "Exportar la base de datos SQLite", "extension": "Extensión", "external": "Externo", "external_libraries": "Bibliotecas externas", @@ -1192,7 +1195,6 @@ "features": "Características", "features_in_development": "Funciones en Desarrollo", "features_setting_description": "Administrar las funciones de la aplicación", - "file_name": "Nombre de archivo:{file_name}", "file_name_or_extension": "Nombre del archivo o extensión", "file_size": "Tamaño del archivo", "filename": "Nombre del archivo", @@ -1236,7 +1238,7 @@ "group_places_by": "Agrupar lugares por...", "group_year": "Agrupar por año", "haptic_feedback_switch": "Activar respuesta háptica", - "haptic_feedback_title": "Respuesta Háptica", + "haptic_feedback_title": "Respuesta háptica", "has_quota": "Cuota asignada", "hash_asset": "Generar hash del archivo", "hashed_assets": "Archivos con hash generado", @@ -1864,7 +1866,7 @@ "reset_pin_code_description": "Si olvidaste tu código PIN, puedes comunicarte con el administrador del servidor para restablecerlo", "reset_pin_code_success": "Código PIN restablecido correctamente", "reset_pin_code_with_password": "Siempre puedes restablecer tu código PIN usando tu contraseña", - "reset_sqlite": "Restablecer la Base de Datos SQLite", + "reset_sqlite": "Restablecer la base de datos SQLite", "reset_sqlite_confirmation": "¿Estás seguro que deseas restablecer la base de datos SQLite? Deberás cerrar sesión y volver a iniciarla para resincronizar los datos", "reset_sqlite_success": "Restablecer exitosamente la base de datos SQLite", "reset_to_default": "Restablecer los valores predeterminados", @@ -1886,7 +1888,7 @@ "role_viewer": "Visor", "running": "En ejecución", "save": "Guardar", - "save_to_gallery": "Guardado en la galería", + "save_to_gallery": "Guardar en la galería", "saved": "Guardado", "saved_api_key": "Clave API guardada", "saved_profile": "Perfil guardado", @@ -2177,8 +2179,8 @@ "sync": "Sincronizar", "sync_albums": "Sincronizar álbumes", "sync_albums_manual_subtitle": "Sincroniza todos los videos y fotos subidos con los álbumes seleccionados a respaldar", - "sync_local": "Sincronización Local", - "sync_remote": "Sincronización Remota", + "sync_local": "Sincronización local", + "sync_remote": "Sincronización remota", "sync_status": "Estado de la sincronización", "sync_status_subtitle": "Ver y gestionar el estado de la sincronización", "sync_upload_album_setting_subtitle": "Crea y sube tus fotos y videos a los álbumes seleccionados en Immich", @@ -2295,6 +2297,7 @@ "upload_details": "Cargar Detalles", "upload_dialog_info": "¿Quieres hacer una copia de seguridad al servidor de los elementos seleccionados?", "upload_dialog_title": "Subir elementos", + "upload_error_with_count": "Error al cargar {count, plural, one {# asset} other {# assets}}", "upload_errors": "Subida completada con {count, plural, one {# error} other {# errores}}, actualice la página para ver los nuevos recursos de la subida.", "upload_finished": "Carga finalizada", "upload_progress": "Restante {remaining, number} - Procesado {processed, number}/{total, number}", diff --git a/i18n/et.json b/i18n/et.json index 899c785ac1..61d4e5b949 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Asetus", "asset_list_settings_subtitle": "Fotoruudustiku asetuse sätted", "asset_list_settings_title": "Fotoruudustik", + "asset_not_found_on_device_android": "Üksust ei leitud seadmest", + "asset_not_found_on_device_ios": "Üksust ei leitud seadmest. Kui kasutad iCloud'i, võib üksus olla iCloud'is oleva vigase faili tõttu kättesaamatu", + "asset_not_found_on_icloud": "Üksust ei leitud iCloud'ist. Üksus võib olla iCloud'is oleva vigase faili tõttu kättesaamatu", "asset_offline": "Üksus pole kättesaadav", "asset_offline_description": "Seda välise kogu üksust ei leitud kettalt. Abi saamiseks palun võta ühendust oma Immich'i administraatoriga.", "asset_restored_successfully": "Üksus edukalt taastatud", @@ -779,6 +782,8 @@ "client_cert_import": "Impordi", "client_cert_import_success_msg": "Klientsertifikaat on imporditud", "client_cert_invalid_msg": "Vigane sertifikaadi fail või vale parool", + "client_cert_password_message": "Sisesta sertifikaadi salasõna", + "client_cert_password_title": "Sertifikaadi salasõna", "client_cert_remove_msg": "Klientsertifikaat on eemaldatud", "client_cert_subtitle": "Toetab ainult PKCS12 (.p12, .pfx) formaati. Sertifikaadi importimine/eemaldamine on saadaval ainult enne sisselogimist", "client_cert_title": "SSL klientsertifikaat [EKSPERIMENTAALNE]", @@ -988,12 +993,12 @@ "edit_title": "Muuda pealkirja", "edit_user": "Muuda kasutajat", "edit_workflow": "Muuda töövoogu", - "editor": "Muutja", + "editor": "Redaktor", "editor_close_without_save_prompt": "Muudatusi ei salvestata", - "editor_close_without_save_title": "Sulge muutja?", + "editor_close_without_save_title": "Sulge redaktor?", "editor_confirm_reset_all_changes": "Kas oled kindel, et soovid kõik muudatused tühistada?", - "editor_flip_horizontal": "Pööra horisontaalselt", - "editor_flip_vertical": "Pööra vertikaalselt", + "editor_flip_horizontal": "Peegelda horisontaalselt", + "editor_flip_vertical": "Peegelda vertikaalselt", "editor_orientation": "Orientatsioon", "editor_reset_all_changes": "Tühista muudatused", "editor_rotate_left": "Pööra 90° vastupäeva", @@ -1192,8 +1197,9 @@ "features": "Funktsioonid", "features_in_development": "Arendusjärgus olevad funktsioonid", "features_setting_description": "Halda rakenduse funktsioone", - "file_name": "Failinimi: {file_name}", "file_name_or_extension": "Failinimi või -laiend", + "file_name_text": "Faili nimi", + "file_name_with_value": "Faili nimi: {file_name}", "file_size": "Failisuurus", "filename": "Failinimi", "filetype": "Failitüüp", @@ -1559,7 +1565,7 @@ "new_pin_code": "Uus PIN-kood", "new_pin_code_subtitle": "See on sul esimene kord lukustatud kausta kasutada. Turvaliseks ligipääsuks loo PIN-kood", "new_timeline": "Uus ajajoon", - "new_update": "Uus uuendus", + "new_update": "Uus versioon", "new_user_created": "Uus kasutaja lisatud", "new_version_available": "UUS VERSIOON SAADAVAL", "newest_first": "Uuemad eespool", @@ -1597,6 +1603,7 @@ "no_results_description": "Proovi sünonüümi või üldisemat märksõna", "no_shared_albums_message": "Lisa album, et fotosid ja videosid teistega jagada", "no_uploads_in_progress": "Üleslaadimisi käimas ei ole", + "none": "Puudub", "not_allowed": "Keelatud", "not_available": "Pole saadaval", "not_in_any_album": "Pole üheski albumis", @@ -2294,6 +2301,7 @@ "upload_details": "Üleslaadimise üksikasjad", "upload_dialog_info": "Kas soovid valitud üksuse(d) serverisse varundada?", "upload_dialog_title": "Üksuse üleslaadimine", + "upload_error_with_count": "Viga {count, plural, one {# üksuse} other {# üksuse}} üleslaadimisel", "upload_errors": "Üleslaadimine lõpetatud {count, plural, one {# veaga} other {# veaga}}, uute üksuste nägemiseks värskenda lehte.", "upload_finished": "Üleslaadimine lõpetatud", "upload_progress": "Ootel {remaining, number} - Töödeldud {processed, number}/{total, number}", diff --git a/i18n/fa.json b/i18n/fa.json index e246086094..e7d681d92f 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -518,7 +518,6 @@ "external_libraries": "کتابخانه‌های خارجی", "favorite": "علاقه‌مندی", "favorites": "علاقه‌مندی‌ها", - "file_name": "نام فایل", "file_name_or_extension": "نام فایل یا پسوند", "filename": "نام فایل", "filetype": "نوع فایل", diff --git a/i18n/fi.json b/i18n/fi.json index fb048da219..a47cd53933 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -1177,7 +1177,6 @@ "features": "Ominaisuudet", "features_in_development": "Kehityksessä olevat ominaisuudet", "features_setting_description": "Hallitse sovelluksen ominaisuuksia", - "file_name": "Tiedoston nimi: {file_name}", "file_name_or_extension": "Tiedostonimi tai tiedostopääte", "file_size": "Tiedostokoko", "filename": "Tiedostonimi", diff --git a/i18n/fr.json b/i18n/fr.json index b79ccf2660..6086a3f88c 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Disposition", "asset_list_settings_subtitle": "Paramètres de disposition de la grille de photos", "asset_list_settings_title": "Grille de photos", + "asset_not_found_on_device_android": "Média introuvable sur l'appareil", + "asset_not_found_on_device_ios": "Média introuvable sur l'appareil. Si vous utilisez iCloud, le média peut être inaccessible en raison d'un fichier corrompu stocké sur iCloud", + "asset_not_found_on_icloud": "Média introuvable sur iCloud. Le média est peut-être inaccessible en raison d'un fichier corrompu stocké sur iCloud", "asset_offline": "Média hors ligne", "asset_offline_description": "Ce média externe n'est plus accessible sur le disque. Veuillez contacter votre administrateur Immich pour obtenir de l'aide.", "asset_restored_successfully": "Élément restauré avec succès", @@ -864,7 +867,7 @@ "custom_locale": "Paramètres régionaux personnalisés", "custom_locale_description": "Afficher les dates et nombres en fonction des paramètres régionaux", "custom_url": "URL personnalisée", - "cutoff_date_description": "Conservez les photos depuis le dernier…", + "cutoff_date_description": "Conservez les photos depuis les derniers…", "cutoff_day": "{count, plural, one {jour} other {jours}}", "cutoff_year": "{count, plural, one {année} other {années}}", "daily_title_text_date": "E, dd MMM", @@ -1192,7 +1195,6 @@ "features": "Fonctionnalités", "features_in_development": "Fonctionnalités en développement", "features_setting_description": "Gérer les fonctionnalités de l'application", - "file_name": "Nom du fichier : {file_name}", "file_name_or_extension": "Nom du fichier ou extension", "file_size": "Taille du fichier", "filename": "Nom du fichier", @@ -2226,7 +2228,7 @@ "to_parent": "Aller au dossier parent", "to_select": "pour faire une sélection", "to_trash": "Corbeille", - "toggle_settings": "Inverser les paramètres", + "toggle_settings": "Afficher/masquer les paramètres", "toggle_theme_description": "Changer le thème", "total": "Total", "total_usage": "Utilisation globale", @@ -2295,6 +2297,7 @@ "upload_details": "Détails des envois", "upload_dialog_info": "Voulez-vous sauvegarder la sélection vers le serveur ?", "upload_dialog_title": "Envoyer le média", + "upload_error_with_count": "Erreur de chargement pour {count, plural, one {# média} other {# médias}}", "upload_errors": "L'envoi s'est complété avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchissez la page pour voir les nouveaux médias envoyés.", "upload_finished": "Envoi fini", "upload_progress": "{remaining, number} restant(s) - {processed, number} traité(s)/{total, number}", diff --git a/i18n/ga.json b/i18n/ga.json index 2f5638e4d8..464e8d9763 100644 --- a/i18n/ga.json +++ b/i18n/ga.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Leagan Amach", "asset_list_settings_subtitle": "Socruithe leagan amach eangach grianghraf", "asset_list_settings_title": "Eangach Grianghraf", + "asset_not_found_on_device_android": "Níor aimsíodh an tsócmhainn ar an ngléas", + "asset_not_found_on_device_ios": "Sócmhainn gan teacht ar an ngléas. Má tá iCloud in úsáid agat, b'fhéidir nach bhfuil an tsócmhainn inrochtana mar gheall ar chomhad lochtach atá stóráilte ar iCloud", + "asset_not_found_on_icloud": "Sócmhainn gan teacht ar iCloud. B’fhéidir nach bhfuil an tsócmhainn inrochtana mar gheall ar chomhad lochtach atá stóráilte ar iCloud", "asset_offline": "Sócmhainn As Líne", "asset_offline_description": "Níl an tsócmhainn sheachtrach seo le fáil ar dhiosca a thuilleadh. Téigh i dteagmháil le riarthóir do Immich le haghaidh cabhrach.", "asset_restored_successfully": "Athchóiríodh an tsócmhainn go rathúil", @@ -1192,7 +1195,6 @@ "features": "Gnéithe", "features_in_development": "Gnéithe i bhForbairt", "features_setting_description": "Bainistigh gnéithe an aip", - "file_name": "Ainm comhaid: {file_name}", "file_name_or_extension": "Ainm comhaid nó síneadh", "file_size": "Méid comhaid", "filename": "Ainm comhaid", @@ -2295,6 +2297,7 @@ "upload_details": "Sonraí Uaslódála", "upload_dialog_info": "Ar mhaith leat cúltaca den Shócmhainn/na Sócmhainní roghnaithe a dhéanamh chuig an bhfreastalaí?", "upload_dialog_title": "Uaslódáil Sócmhainn", + "upload_error_with_count": "Earráid uaslódála le haghaidh {count, plural, one {# sócmhainn} other {# sócmhainní}}", "upload_errors": "Uaslódáil críochnaithe le {count, plural, one {# earráid} other {# earráidí}}, athnuachan an leathanach chun sócmhainní uaslódála nua a fheiceáil.", "upload_finished": "Uaslódáil críochnaithe", "upload_progress": "Fágtha {remaining, number} - Próiseáilte {processed, number}/{total, number}", diff --git a/i18n/gl.json b/i18n/gl.json index 9127d04978..f3717259ee 100644 --- a/i18n/gl.json +++ b/i18n/gl.json @@ -1139,7 +1139,6 @@ "features": "Funcións", "features_in_development": "Funcionalidades en Desenvolvemento", "features_setting_description": "Xestionar as funcións da aplicación", - "file_name": "Nome do ficheiro", "file_name_or_extension": "Nome do ficheiro ou extensión", "file_size": "Tamaño do arquivo", "filename": "Nome do ficheiro", diff --git a/i18n/gsw.json b/i18n/gsw.json index 0615722e34..0d8b7abf3a 100644 --- a/i18n/gsw.json +++ b/i18n/gsw.json @@ -1129,7 +1129,6 @@ "features": "Funktione", "features_in_development": "Feature isch in Entwicklig", "features_setting_description": "Funkione i de App verwalte", - "file_name": "Dateiname", "file_name_or_extension": "Dateiname oder -erwiiterig", "file_size": "Dateigrössi", "filename": "Dateiname", diff --git a/i18n/he.json b/i18n/he.json index 76762175df..7884cea268 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -1127,7 +1127,6 @@ "features": "תכונות", "features_in_development": "תכונות בפיתוח", "features_setting_description": "ניהול תכונות היישום", - "file_name": "שם הקובץ", "file_name_or_extension": "שם קובץ או סיומת", "file_size": "גודל קובץ", "filename": "שם קובץ", diff --git a/i18n/hi.json b/i18n/hi.json index f583ee2411..959a3aaf73 100644 --- a/i18n/hi.json +++ b/i18n/hi.json @@ -1112,7 +1112,6 @@ "features": "विशेषताएँ", "features_in_development": "विकास में सुविधाएँ", "features_setting_description": "ऐप सुविधाओं का प्रबंधन करें", - "file_name": "फ़ाइल का नाम", "file_name_or_extension": "फ़ाइल का नाम या एक्सटेंशन", "file_size": "फ़ाइल का साइज़", "filename": "फ़ाइल का नाम", diff --git a/i18n/hr.json b/i18n/hr.json index 40dcdc3fe4..83cf002c37 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -1094,7 +1094,6 @@ "features": "Značajke", "features_in_development": "Značajke u razvoju", "features_setting_description": "Upravljajte značajkama aplikacije", - "file_name": "Naziv datoteke", "file_name_or_extension": "Naziv ili ekstenzija datoteke", "file_size": "Veličina datoteke", "filename": "Naziv datoteke", diff --git a/i18n/hu.json b/i18n/hu.json index 7d91afee76..232d492dd7 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -272,7 +272,7 @@ "oauth_auto_register": "Automatikus regisztráció", "oauth_auto_register_description": "Új felhasználók automatikus regisztrálása az OAuth használatával történő bejelentkezés után", "oauth_button_text": "Gomb szövege", - "oauth_client_secret_description": "Kötelező, ha az OAuth szolgáltató nem támogatja a PKCE-t (Proof Key for Code Exchange)", + "oauth_client_secret_description": "Bizalmas kliens esetén kötelező, vagy ha az OAuth szolgáltató nem támogatja a PKCE-t (Proof Key for Code Exchange) nyilvános kliensnél.", "oauth_enable_description": "Bejelentkezés OAuth használatával", "oauth_mobile_redirect_uri": "Mobil átirányítási URI", "oauth_mobile_redirect_uri_override": "Mobil átirányítási URI felülírás", @@ -351,7 +351,7 @@ "template_settings": "Értesítés sablonok", "template_settings_description": "Egyéni sablonok kezelése az értesítésekhez", "theme_custom_css_settings": "Egyéni CSS", - "theme_custom_css_settings_description": "CSS Stíluslapokkal az Immich stílusa megváltoztatható.", + "theme_custom_css_settings_description": "Cascading Style Sheet stíluslapokkal az Immich stílusa megváltoztatható.", "theme_settings": "Téma beállítások", "theme_settings_description": "Az Immich webes felületének testreszabása", "thumbnail_generation_job": "Bélyegképek generálása", @@ -359,7 +359,7 @@ "transcoding_acceleration_api": "Gyorsító API", "transcoding_acceleration_api_description": "Az átkódolás felgyorsításához használt eszközödhöz tartozó API. Ez a beállítás „legtöbb, amit megtehetünk” alapon működik: probléma esetén visszaáll szoftveres átkódolásra. A VP9 a hardvertől függően vagy működik, vagy nem.", "transcoding_acceleration_nvenc": "NVENC (NVIDIA GPU-t igényel)", - "transcoding_acceleration_qsv": "Gyors Szinkronizálás (7. generációs vagy újabb Intel CPU-t igényel)", + "transcoding_acceleration_qsv": "Quick Sync (7. generációs vagy újabb Intel CPU-t igényel)", "transcoding_acceleration_rkmpp": "RKMPP (csak Rockchip SOC-on)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Elfogadott audio kodekek", @@ -451,6 +451,9 @@ "admin_password": "Admin jelszó", "administration": "Adminisztráció", "advanced": "Haladó", + "advanced_settings_clear_image_cache": "Fényképek gyorsítótárának kiürítése", + "advanced_settings_clear_image_cache_error": "Fényképek gyorsítótárának kiürítése sikertelen", + "advanced_settings_clear_image_cache_success": "{size} sikeresen felszabadítva", "advanced_settings_enable_alternate_media_filter_subtitle": "Ezzel a beállítással a szinkronizálás során alternatív kritériumok alapján szűrheted a fájlokat. Csak akkor próbáld ki, ha problémáid vannak azzal, hogy az alkalmazás nem ismeri fel az összes albumot.", "advanced_settings_enable_alternate_media_filter_title": "[KÍSÉRLETI] Alternatív eszköz album szinkronizálási szűrő használata", "advanced_settings_log_level_title": "Naplózás szintje: {level}", @@ -514,6 +517,7 @@ "all": "Mind", "all_albums": "Minden album", "all_people": "Minden személy", + "all_photos": "Minden fénykép", "all_videos": "Minden videó", "allow_dark_mode": "Sötét téma engedélyezése", "allow_edits": "Módosítások engedélyezése", @@ -521,6 +525,9 @@ "allow_public_user_to_upload": "Engedélyezi a feltöltést publikus felhasználó számára", "allowed": "Engedélyezett", "alt_text_qr_code": "QR kód kép", + "always_keep": "Tartsa meg mindig", + "always_keep_photos_hint": "A tárhely-felszabadítás nem törli az eszközön található fényképeket.", + "always_keep_videos_hint": "A tárhely-felszabadítás nem törli az eszközön található videókat.", "anti_clockwise": "Óramutató járásával ellentétes irány", "api_key": "API kulcs", "api_key_description": "Ez csak most az egyszer jelenik meg. Az ablak bezárása előtt feltétlenül másold.", @@ -565,6 +572,9 @@ "asset_list_layout_sub_title": "Elrendezés", "asset_list_settings_subtitle": "Fotórács elrendezése", "asset_list_settings_title": "Fotórács", + "asset_not_found_on_device_android": "Az elem nem található az eszközön", + "asset_not_found_on_device_ios": "Az elem nem található az eszközön. Ha az iCloud-ot használod, az elem lehet hogy azért nem elérhető, mert rossz fájl van tárolva az iCloud-on", + "asset_not_found_on_icloud": "Az elem nem található az iCloud-on. Lehet, hogy azért nem elérhető, mert rossz fájl van az iCloud-on tárolva", "asset_offline": "Elem offline", "asset_offline_description": "Ez a külső elem már nem elérhető a lemezen. Kérlek, lépj kapcsolatba az Immich adminisztrátorával.", "asset_restored_successfully": "Elem sikeresen helyreállítva", @@ -605,11 +615,11 @@ "autoplay_slideshow": "Automatikus diavetítés", "back": "Vissza", "back_close_deselect": "Vissza, bezárás, vagy kijelölés törlése", - "background_backup_running_error": "Háttérben futó mentés folyamatban, kézi mentés nem indítható", + "background_backup_running_error": "Háttérben futó mentés folyamatban, a kézi mentés nem indítható", "background_location_permission": "Háttérben történő helymeghatározási engedély", "background_location_permission_content": "Hálózatok automatikus váltásához az Immich-nek *mindenképpen* hozzá kell férnie a pontos helyzethez, hogy az alkalmazás le tudja kérni a Wi-Fi hálózat nevét", "background_options": "Háttérbeli futás beállításai", - "backup": "Mentés", + "backup": "Biztonsági Mentés", "backup_album_selection_page_albums_device": "Ezen az eszközön lévő albumok ({count})", "backup_album_selection_page_albums_tap": "Koppints a hozzáadáshoz, duplán koppints az eltávolításhoz", "backup_album_selection_page_assets_scatter": "Egy elem több albumban is lehet. Ezért a mentéshez albumokat lehet hozzáadni vagy azokat a mentésből kihagyni.", @@ -688,6 +698,7 @@ "blurred_background": "Homályos háttér", "bugs_and_feature_requests": "Hibabejelentés és új funkció kérése", "build": "Felépítés", + "build_image": "Kép elkészítése", "bulk_delete_duplicates_confirmation": "Biztosan kitörölsz {count, plural, one {# duplikált elemet} other {# duplikált elemet}}? A művelet a legnagyobb méretű elemet tartja meg minden hasonló csoportból és minden másik duplikált elemet kitöröl. Ez a művelet nem visszavonható!", "bulk_keep_duplicates_confirmation": "Biztosan meg szeretnél tartani {count, plural, other {# egyező elemet}}? Ez a művelet az elemek törlése nélkül megszünteti az összes duplikált csoportosítást.", "bulk_trash_duplicates_confirmation": "Biztosan kitörölsz {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? Ez a művelet megtartja minden csoportból a legnagyobb méretű elemet, és kitöröl minden másik duplikáltat.", @@ -753,11 +764,12 @@ "cleanup_deleted_assets": "{count} elem áthelyezve az eszköz lomtárába", "cleanup_deleting": "Lomtárba helyezés...", "cleanup_found_assets": "{count} feltöltött elem találva", + "cleanup_found_assets_with_size": "{count} feltöltött elem találva ({size})", "cleanup_icloud_shared_albums_excluded": "Megosztott iCloud albumok nem kerülnek átnézésre", - "cleanup_no_assets_found": "Nincs feltöltött elem ezekkel a kritériumokkal", + "cleanup_no_assets_found": "Nincs elem ezekkel a kritériumokkal. Tárhely felszabadításakor csak olyan elemeket törölhet, amelyek már fel lettek töltve a szerverre", "cleanup_preview_title": "Törlendő elemek ({count})", - "cleanup_step3_description": "Szerverre feltöltött képek és videók keresése dátum és egyéb megadott szűrési kritériumok szerint", - "cleanup_step4_summary": "{count} {date} előtti elem eltávolításra fog kerülni erről az eszközről", + "cleanup_step3_description": "Szerverre feltöltött elemek keresése dátum és egyéb megadott szűrési kritériumok szerint.", + "cleanup_step4_summary": "{count} {date} előtti elem eltávolításra fog kerülni erről az eszközről. Az elemek továbbra is elérhetők lesznek az Immich alkalmazásban.", "cleanup_trash_hint": "A tárhely visszanyeréséhez nyisd meg a beépített galéria alkalmazást és töröld a lomtárat", "clear": "Törlés", "clear_all": "Alaphelyzet", @@ -786,7 +798,7 @@ "comments_are_disabled": "A megjegyzések le vannak tiltva", "common_create_new_album": "Új album létrehozása", "completed": "Kész", - "confirm": "Jóváhagy", + "confirm": "Jóváhagyás", "confirm_admin_password": "Admin jelszó megerősítése", "confirm_delete_face": "Biztos, hogy törölni szeretnéd a(z) {name} arcát az elemről?", "confirm_delete_shared_link": "Biztosan törölni szeretnéd ezt a megosztott linket?", @@ -855,7 +867,7 @@ "custom_locale": "Egyéni területi beállítás", "custom_locale_description": "Dátumok és számok formázása a nyelv és terület szerint", "custom_url": "Egyéni URL", - "cutoff_date_description": "Ennél régebbi fotók és videók eltávolítása", + "cutoff_date_description": "Fotók megtartása az elmúlt…", "cutoff_day": "{count, plural, one {nap} other {nap}}", "cutoff_year": "{count, plural, one {év} other {év}}", "daily_title_text_date": "MMM dd (E)", @@ -879,7 +891,7 @@ "default_locale": "Alapértelmezett területi beállítás", "default_locale_description": "Dátumok és számok formázása a böngésződ területi beállítása alapján", "delete": "Törlés", - "delete_action_confirmation_message": "Biztosan törölni szeretnéd ezt az elemet? Így az elem a szerver lomtárába kerül, és a megkérdezi, hogy törölni szeretnéd-e a helyi másolatot is", + "delete_action_confirmation_message": "Biztosan törölni szeretnéd ezt az elemet? Így az elem a szerver lomtárába kerül, és megkérdezi, hogy törölni szeretnéd-e a az eszközön is", "delete_action_prompt": "{count} törölve", "delete_album": "Album törlése", "delete_api_key_prompt": "Biztosan törölni szeretnéd ezt az API kulcsot?", @@ -1007,6 +1019,7 @@ "error_change_sort_album": "Album sorbarendezésének megváltoztatása sikertelen", "error_delete_face": "Hiba az arc törlése során", "error_getting_places": "Hiba a helyek betöltésekor", + "error_loading_albums": "Hiba az albumok betöltésekor", "error_loading_image": "Hiba a kép betöltése közben", "error_loading_partners": "Hiba a partnerek betöltésénél: {error}", "error_retrieving_asset_information": "Hiba az elem adatainak lekérése közben", @@ -1182,7 +1195,6 @@ "features": "Beállítások", "features_in_development": "Folyamatban lévő fejlesztések", "features_setting_description": "Az alkalmazás jellemzőinek kezelése", - "file_name": "Fájlnév: {file_name}", "file_name_or_extension": "Fájlnév vagy kiterjesztés", "file_size": "Fájlméret", "filename": "Fájlnév", @@ -1202,7 +1214,7 @@ "forgot_pin_code_question": "Elfelejtetted a PIN kódod?", "forward": "Előre", "free_up_space": "Tárhely felszabadítása", - "free_up_space_description": "Hely felszabadítása érdekében helyezze át a mentett fotókat és videókat az eszköz kukájába. A szerveren lévő másolatok biztonságban maradnak", + "free_up_space_description": "Hely felszabadítása érdekében helyezze át a mentett fotókat és videókat az eszköz kukájába. A szerveren lévő másolatok biztonságban maradnak.", "free_up_space_settings_subtitle": "Eszköz tárhely felszabadítása", "full_path": "Teljes eléréi útvonal: {path}", "gcast_enabled": "Google Cast", @@ -1319,9 +1331,15 @@ "json_editor": "JSON szerkesztő", "json_error": "JSON hiba", "keep": "Megtart", + "keep_albums": "Albumok megtartása", + "keep_albums_count": "{count} album megtartása", "keep_all": "Összes megtartása", + "keep_description": "Válaszd ki, mi maradjon az eszközödön tárhely felszabadításakor.", "keep_favorites": "Kedvencek megtartása", + "keep_on_device": "Maradjon az eszközön", + "keep_on_device_hint": "Válaszd ki az eszközön tartandó elemeket", "keep_this_delete_others": "Ennek a meghagyása, a többi törlése", + "keeping": "Meg lesz tartva: {items}", "kept_this_deleted_others": "Ez az elem és a töröltek meg lettek hagyva {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Billentyűparancsok", "language": "Nyelv", @@ -1398,7 +1416,7 @@ "login_form_failed_get_oauth_server_config": "Nem sikerült az OAuth bejelentkezés. Ellenőrizd a szerver URL-t", "login_form_failed_get_oauth_server_disable": "OAuth bejelentkezés nem elérhető ezen a szerveren", "login_form_failed_login": "Hiba a bejelentkezés közben, ellenőrizd a szerver címét, az emailt és a jelszót", - "login_form_handshake_exception": "SSL Kézfogási Hiba törént. Engedélyezd az önaláírt tanúsítvényokat a beállításokban, hogy ha önaláírt tanúsítványt használsz.", + "login_form_handshake_exception": "Handshake hiba történt a szerverrel. Engedélyezd a saját aláírású tanúsítványok használatát a beállításokban, ha ilyen tanúsítványt használsz.", "login_form_password_hint": "jelszó", "login_form_save_login": "Maradjon bejelentkezve", "login_form_server_empty": "Add meg a szerver címét.", @@ -1422,7 +1440,7 @@ "maintenance_logged_in_as": "Bejelentkezve mint: {user}", "maintenance_restore_from_backup": "Helyreállítás biztonsági mentésből", "maintenance_restore_library": "Könyvtár helyreállítása", - "maintenance_restore_library_confirm": "Ha ez jónak tűnik,", + "maintenance_restore_library_confirm": "Ha ez jónak tűnik, tovább a biztonsági mentés visszaállítására!", "maintenance_restore_library_description": "Adatbázis helyreállítása", "maintenance_restore_library_folder_has_files": "{folder} {count} mappával rendelkezik", "maintenance_restore_library_folder_no_files": "{folder}-ból/-ből fájlok hiányoznak!", @@ -1551,6 +1569,7 @@ "next_memory": "Következő emlék", "no": "Nem", "no_actions_added": "Még nincsenek műveletek", + "no_albums_found": "Nem találhatók albumok", "no_albums_message": "Fotóid és videóid rendszerezéséhez hozz létre egy új albumot", "no_albums_with_name_yet": "Úgy tűnik, hogy ilyen névvel még nincs albumod.", "no_albums_yet": "Úgy tűnik, hogy még egy albumod sincs.", @@ -1580,6 +1599,7 @@ "no_results_description": "Próbálkozz szinonimákkal vagy általánosabb kulcsszavakkal", "no_shared_albums_message": "Hozz létre egy új albumot, hogy megoszthasd fényképeid és videóid másokkal", "no_uploads_in_progress": "Nincs folyamatban lévő feltöltés", + "none": "Semelyik", "not_allowed": "Nem engedélyezett", "not_available": "N/A", "not_in_any_album": "Nincs albumban", @@ -1877,7 +1897,7 @@ "scaffold_body_error_occurred": "Hiba történt", "scan": "Átfésül", "scan_all_libraries": "Minden képtár átfésülése", - "scan_library": "Scan", + "scan_library": "Beolvasás", "scan_settings": "Átfésülési beállítások", "scanning": "Átfésülés folyamatban", "scanning_for_album": "Albumok átfésülése...", @@ -1952,6 +1972,7 @@ "select_all_in": "Összes kijelölése itt: {group}", "select_avatar_color": "Avatár színének választása", "select_count": "{count, plural, one {# kiválasztása} other {# kiválasztása}}", + "select_cutoff_date": "Határdátum választása", "select_face": "Arc kiválasztása", "select_featured_photo": "Alapértelmezett fénykép kiválasztása", "select_from_computer": "Kiválasztás a számítógépről", @@ -2126,7 +2147,7 @@ "sort_recent": "Legújabb fénykép", "sort_title": "Cím", "source": "Forrás", - "stack": "Fotók csoportosítása", + "stack": "Kollázs", "stack_action_prompt": "{count} egymásra helyezve", "stack_duplicates": "Duplikátumok csoportosítása", "stack_select_one_photo": "Válassz egy fő képet a csoportból", @@ -2191,6 +2212,7 @@ "theme_setting_theme_subtitle": "Alkalmazás témájának választása", "theme_setting_three_stage_loading_subtitle": "A háromlépcsős betöltés javíthatja a betöltési teljesítményt, de jelentősen növeli a hálózati forgalmat", "theme_setting_three_stage_loading_title": "Háromlépcsős betöltés engedélyezése", + "then": "Akkor", "they_will_be_merged_together": "Egyesítve lesznek", "third_party_resources": "Harmadik féltől származó források", "time": "Idő", @@ -2275,6 +2297,7 @@ "upload_details": "Feltöltés állapota", "upload_dialog_info": "Szeretnél mentést készíteni a kiválasztott elem(ek)ről a szerverre?", "upload_dialog_title": "Elem feltöltése", + "upload_error_with_count": "Feltöltési hiba {count} elemnél", "upload_errors": "Feltöltés befejezve {count, plural, other {# hibával}}, frissítsd az oldalt az újonnan feltöltött elemek megtekintéséhez.", "upload_finished": "Feltöltés befejezve", "upload_progress": "{remaining, number} hátra van - {processed, number}/{total, number} feldolgozva", @@ -2343,6 +2366,8 @@ "viewer_stack_use_as_main_asset": "Fő elemnek beállítás", "viewer_unstack": "Csoport megszüntetése", "visibility_changed": "{count, plural, other {# személy}} láthatósága megváltozott", + "visual": "Vizuális", + "visual_builder": "Vizuális összerakó", "waiting": "Várakozik", "waiting_count": "Várakozik: {count}", "warning": "Figyelmeztetés", diff --git a/i18n/id.json b/i18n/id.json index 2f85f23912..6f00e98867 100644 --- a/i18n/id.json +++ b/i18n/id.json @@ -5,21 +5,21 @@ "acknowledge": "Mengerti", "action": "Tindakan", "action_common_update": "Perbarui", - "action_description": "Sebuah kelompok perbuatan untuk melakukan suatu aksi pada aset-aset yang terfiltrasi", + "action_description": "Tindakan yang perlu dijalankan pada aset yang terfilter", "actions": "Tindakan", "active": "Aktif", "active_count": "Aktif: {count}", "activity": "Aktivitas", - "activity_changed": "Aktivitas {enabled, select, true {diaktifkan} other {dinonaktifkan}}", + "activity_changed": "Aktivitas {enabled, select, true {aktif} other {nonaktif}}", "add": "Tambahkan", - "add_a_description": "Tambahkan sebuah deskripsi", + "add_a_description": "Tambah keterangan", "add_a_location": "Tambahkan lokasi", "add_a_name": "Tambahkan nama", "add_a_title": "Tambahkan judul", - "add_action": "Tambah aksi", - "add_action_description": "Klik untuk menambahkan aksi yang akan dilakukan", + "add_action": "Tambah tindakan", + "add_action_description": "Klik untuk menambahkan tindakan yang perlu dijalankan", "add_assets": "Tambahkan aset", - "add_birthday": "Tambahkan Tanggal Lahir", + "add_birthday": "Tambahkan tanggal lahir", "add_endpoint": "Tambahkan titik akhir", "add_exclusion_pattern": "Tambahkan pola pengecualian", "add_filter": "Tambahkan filter", @@ -104,6 +104,8 @@ "image_preview_description": "Gambar berukuran sedang tanpa metadata, digunakan ketika melihat aset satuan dan untuk pembelajaran mesin", "image_preview_quality_description": "Kualitas pratinjau dari 1-100. Lebih tinggi lebih baik, tetapi menghasilkan berkas lebih besar dan respons aplikasi. Menetapkan nilai rendah dapat memengaruhi kualitas pembelajaran mesin.", "image_preview_title": "Pengaturan Pratinjau", + "image_progressive": "Progresif", + "image_progressive_description": "Enkode gambar-gambar JPEG secara progresif untuk memuat tampilan secara bertahap. Ini tidak berpengaruh pada gambar-gambar WebP.", "image_quality": "Kualitas", "image_resolution": "Resolusi", "image_resolution_description": "Resolusi yang lebih tinggi dapat menyimpan lebih banyak detail tetapi memerlukan waktu yang lebih lama untuk di-enkode, memiliki ukuran berkas yang lebih besar, dan dapat mengurangi respons aplikasi.", @@ -188,10 +190,21 @@ "machine_learning_smart_search_enabled": "Aktifkan pencarian pintar", "machine_learning_smart_search_enabled_description": "Jika dinonaktifkan, gambar tidak akan dienkode untuk pencarian pintar.", "machine_learning_url_description": "URL server pembelajaran mesin. Jika lebih dari satu URL disediakan, setiap server akan dicoba satu per satu sampai salah satu berhasil merespons, dari urutan pertama sampai terakhir. Server yang tidak merespons akan diabaikan sementara sampai kembali daring.", + "maintenance_delete_backup": "Hapus Cadangan", + "maintenance_delete_backup_description": "File ini akan dihapus secara permanen.", + "maintenance_delete_error": "Gagal menghapus cadangan.", + "maintenance_restore_backup": "Mengembalikan Cadangan", + "maintenance_restore_backup_description": "Immich akan dihapus dan dikembalikan dari candangan yang dipilih. Sebuah candangan akan dibuat sebelum dilanjutkan.", + "maintenance_restore_backup_different_version": "Cadangan ini dibuat dengan versi Immich yang berbeda!", + "maintenance_restore_backup_unknown_version": "Tidak dapat menentukan versi candangan.", + "maintenance_restore_database_backup": "Mengembalikan cadangan database", + "maintenance_restore_database_backup_description": "Kembalikan ke keadaan database sebelumnya menggunakan sebuah file cadangan", "maintenance_settings": "Pemeliharaan", "maintenance_settings_description": "Setel mode pemeliharaan Immich.", - "maintenance_start": "Mulai mode pemeliharaan", + "maintenance_start": "Pindah ke mode pemeliharaan", "maintenance_start_error": "Gagal memulai mode pemeliharaan.", + "maintenance_upload_backup": "Unggah file candangan database", + "maintenance_upload_backup_error": "Tidak dapat mengunggah cadangan, apakah ini sebuah file .sql/.sql.gz?", "manage_concurrency": "Kelola Konkurensi", "manage_concurrency_description": "Pindah ke halaman tugas untuk mengelola konkurensi tugas", "manage_log_settings": "Kelola pengaturan log", @@ -259,7 +272,7 @@ "oauth_auto_register": "Pendaftaran otomatis", "oauth_auto_register_description": "Daftar pengguna baru secara otomatis setelah log masuk dengan OAuth", "oauth_button_text": "Teks tombol", - "oauth_client_secret_description": "Diperlukan jika PKCE (Proof Key for Code Exchange) tidak didukung oleh penyedia OAuth", + "oauth_client_secret_description": "Diperlukan untuk klien yang konfidensial, atau jika PKCE (Proof Key for Code Exchange) tidak didukung untuk klien umum.", "oauth_enable_description": "Log masuk dengan OAuth", "oauth_mobile_redirect_uri": "URI pengalihan ponsel", "oauth_mobile_redirect_uri_override": "Penimpaan URI penerusan ponsel", @@ -438,6 +451,9 @@ "admin_password": "Kata Sandi Admin", "administration": "Administrasi", "advanced": "Tingkat lanjut", + "advanced_settings_clear_image_cache": "Bersihkan Cache Gambar", + "advanced_settings_clear_image_cache_error": "Gagal untuk membersihkan cache gambar", + "advanced_settings_clear_image_cache_success": "Sukses menghapus {size}", "advanced_settings_enable_alternate_media_filter_subtitle": "Gunakan opsi ini untuk menyaring media saat sinkronisasi berdasarkan kriteria alternatif. Hanya coba ini dengan aplikasi mendeteksi semua album.", "advanced_settings_enable_alternate_media_filter_title": "[EKSPERIMENTAL] Gunakan saringan sinkronisasi album perangkat alternatif", "advanced_settings_log_level_title": "Tingkat log: {level}", @@ -501,6 +517,7 @@ "all": "Semua", "all_albums": "Semua album", "all_people": "Semua orang", + "all_photos": "Semua foto", "all_videos": "Semua video", "allow_dark_mode": "Perbolehkan mode gelap", "allow_edits": "Perbolehkan penyuntingan", @@ -508,6 +525,9 @@ "allow_public_user_to_upload": "Perbolehkan pengguna publik untuk mengunggah", "allowed": "Diijinkan", "alt_text_qr_code": "Gambar kode QR", + "always_keep": "Selalu simpan", + "always_keep_photos_hint": "Fitur Bebaskan Ruang ruang akan menyimpan semua foto di perangkat ini.", + "always_keep_videos_hint": "Fitur Bebaskan Ruang ruang akan menyimpan semua video di perangkat ini.", "anti_clockwise": "Berlawanan arah jarum jam", "api_key": "Kunci API", "api_key_description": "Nilai ini hanya akan ditampilkan sekali. Pastikan untuk menyalin sebelum menutup jendela ini.", @@ -552,6 +572,9 @@ "asset_list_layout_sub_title": "Penataan", "asset_list_settings_subtitle": "Setelan grid foto", "asset_list_settings_title": "Grid Foto", + "asset_not_found_on_device_android": "Aset tidak ditemukan di perangkat", + "asset_not_found_on_device_ios": "Aset tidak ditemukan di perangkat. Jika kamu menggunakan iCloud, aset mungkin tidak dapat diakses karena berkas rusak di iCloud", + "asset_not_found_on_icloud": "Aset tidak ditemukan di iCloud. Aset mungkin tidak dapat diakses karena berkas rusak di iCloud", "asset_offline": "Aset Luring", "asset_offline_description": "Aset eksternal ini tidak ada lagi di diska. Silakan hubungi administrator Immich Anda untuk bantuan.", "asset_restored_successfully": "Aset telah berhasil dipulihkan", @@ -603,7 +626,7 @@ "backup_album_selection_page_select_albums": "Pilih album", "backup_album_selection_page_selection_info": "Info Pilihan", "backup_album_selection_page_total_assets": "Total aset unik", - "backup_albums_sync": "Sinkronisasi cadangan album", + "backup_albums_sync": "Sinkronisasi Cadangan Album", "backup_all": "Semua", "backup_background_service_backup_failed_message": "Gagal mencadangkan aset. Mencoba lagi…", "backup_background_service_complete_notification": "Pencadangan aset selesai", @@ -738,6 +761,16 @@ "city": "Kota", "cleanup_confirm_description": "Immich menemukan {count} aset (dibuat sebelum {date}) telah aman dicadangkan di server. Hapus salinan lokal dari perangkat ini?", "cleanup_confirm_prompt_title": "Hapus dari perangkat ini?", + "cleanup_deleted_assets": "Pindahkan {count} aset ke tempat sampah di perangkat", + "cleanup_deleting": "Memindahkan ke tempat sampah...", + "cleanup_found_assets": "Menemukan {count} aset cadangan", + "cleanup_found_assets_with_size": "Menemukan {count} aset cadangan ({size})", + "cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums dikecualikan dari pemindaian", + "cleanup_no_assets_found": "Tidak ada aset yang ditemukan dengan kriteria diatas. Fitur membebaskan ruang hanya dapat menghapus aset yang dicadangkan ke server", + "cleanup_preview_title": "Aset yang akan dihapus ({count})", + "cleanup_step3_description": "Pindah untuk cadangan aset yang sesuai dengan tanggal mu dan simpan pengaturan.", + "cleanup_step4_summary": "{count} aset (dibuat sebelum {date}) untuk dihapus dari perangkat lokal. Foto akan tetap dapat diakses dari aplikasi Immich.", + "cleanup_trash_hint": "Untuk dapat mengambil semua ruang penyimpanan, buka aplikasi galeri pada sistem dan kosongkan tempat sampah", "clear": "Hapus", "clear_all": "Hapus semua", "clear_all_recent_searches": "Hapus semua pencarian terakhir", @@ -823,13 +856,18 @@ "created_at": "Dibuat", "creating_linked_albums": "Membuat album tertaut...", "crop": "Pangkas", + "crop_aspect_ratio_fixed": "Diperbaiki", + "crop_aspect_ratio_free": "Bebas", + "crop_aspect_ratio_original": "Asli", "curated_object_page_title": "Benda", "current_device": "Perangkat saat ini", "current_pin_code": "Kode PIN saat ini", "current_server_address": "Alamat server saat ini", + "custom_date": "Tanggal kustom", "custom_locale": "Lokal Khusus", "custom_locale_description": "Format tanggal dan angka berdasarkan bahasa dan wilayah", "custom_url": "URL Kustom", + "cutoff_date_description": "Simpan foto dari…", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM yyyy", "dark": "Gelap", @@ -911,6 +949,7 @@ "download_include_embedded_motion_videos": "Video tertanam", "download_include_embedded_motion_videos_description": "Sertakan video yang di sematkan dalam foto bergerak sebagai file terpisah", "download_notfound": "Unduhan tidak ditemukan", + "download_original": "Unduh berkas asli", "download_paused": "Unduhan dijeda", "download_settings": "Unduhan", "download_settings_description": "Kelola pengaturan berkaitan dengan pengunduhan aset", @@ -920,6 +959,7 @@ "download_waiting_to_retry": "Menunggu untuk mencoba lagi", "downloading": "Mengunduh", "downloading_asset_filename": "Mengunduh aset {filename}", + "downloading_from_icloud": "Mengunduh dari iCloud", "downloading_media": "Mengunduh media", "drop_files_to_upload": "Lepaskan berkas di mana saja untuk mengunggah", "duplicates": "Duplikat", @@ -952,6 +992,13 @@ "editor": "Penyunting", "editor_close_without_save_prompt": "Perubahan tidak akan di simpan", "editor_close_without_save_title": "Tutup editor?", + "editor_confirm_reset_all_changes": "Apakah anda yakin mau mengatur ulang semua perubahan?", + "editor_flip_horizontal": "Balik horizontal", + "editor_flip_vertical": "Balik vertikal", + "editor_orientation": "Orientasi", + "editor_reset_all_changes": "Mengatur ulang perubahan", + "editor_rotate_left": "Putar 90° berlawanan arah jarum jam", + "editor_rotate_right": "Putar 90° searah jarum jam", "email": "Surel", "email_notifications": "Notifikasi surel", "empty_folder": "Folder ini kosong", @@ -970,11 +1017,14 @@ "error_change_sort_album": "Gagal mengubah urutan album", "error_delete_face": "Terjadi kesalahan menghapus wajah dari aset", "error_getting_places": "Kesalahan saat mengambil lokasi", + "error_loading_albums": "Gagal memuat album", "error_loading_image": "Terjadi eror memuat gambar", "error_loading_partners": "Kesalahan saat memuat partner: {error}", + "error_retrieving_asset_information": "Gagal mendapatkan informasi aset", "error_saving_image": "Kesalahan: {error}", "error_tag_face_bounding_box": "Galat saat memberi tag wajah – tidak dapat memperoleh koordinat kotak pembatas", "error_title": "Eror - Ada yang salah", + "error_while_navigating": "Gagal saat berpindah ke aset", "errors": { "cannot_navigate_next_asset": "Tidak dapat menuju ke aset berikutnya", "cannot_navigate_previous_asset": "Tidak dapat menuju ke aset sebelumnya", @@ -1098,6 +1148,7 @@ "unable_to_update_workflow": "Tidak dapat memperbarui alur kerja", "unable_to_upload_file": "Tidak dapat mengunggah berkas" }, + "errors_text": "Gagal", "exclusion_pattern": "Pola pengecualian", "exif": "EXIF", "exif_bottom_sheet_description": "Tambahkan Deskripsi...", @@ -1142,7 +1193,6 @@ "features": "Fitur", "features_in_development": "Fitur dalam Pengembangan", "features_setting_description": "Kelola fitur aplikasi", - "file_name": "Nama berkas: {file_name}", "file_name_or_extension": "Nama berkas atau ekstensi", "file_size": "Ukuran berkas", "filename": "Nama berkas", @@ -1161,6 +1211,7 @@ "folders_feature_description": "Menjelajahi tampilan folder untuk foto dan video pada sistem file", "forgot_pin_code_question": "Lupa PIN?", "forward": "Maju", + "free_up_space": "Bebaskan ruang", "full_path": "Jalur lengkap: {path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "Fitur ini memuat sumber daya eksternal dari Google agar dapat berfungsi.", diff --git a/i18n/is.json b/i18n/is.json index a16f23e455..a355b71661 100644 --- a/i18n/is.json +++ b/i18n/is.json @@ -1022,7 +1022,6 @@ "features": "Eiginleikar", "features_in_development": "Eiginleikar í þróun", "features_setting_description": "Sýsla með eiginleika smáforrits", - "file_name": "Skráarheiti", "file_name_or_extension": "Skráarheiti eða nafnauki", "file_size": "Skráarstærð", "filename": "Skráarheiti", diff --git a/i18n/it.json b/i18n/it.json index a11879d57f..50d0632f3d 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -104,6 +104,8 @@ "image_preview_description": "Immagine a media dimensione senza metadati, utilizzata durante la visualizzazione di una singola risorsa e per il machine learning", "image_preview_quality_description": "Qualità dell'anteprima da 1 a 100. Più alto è meglio ma produce file più pesanti e può ridurre la reattività dell'app. Impostare un valore basso può influenzare negativamente la qualità del machine learning.", "image_preview_title": "Impostazioni dell'anteprima", + "image_progressive": "Progressiva", + "image_progressive_description": "Codifica progressivamente le immagini JPEG per mostrarle con un caricamento graduale. Questo non ha effetto sulle immagini WebP.", "image_quality": "Qualità", "image_resolution": "Risoluzione", "image_resolution_description": "Risoluzioni più elevate possono preservare più dettagli ma richiedere più tempo per la codifica, avere dimensioni di file più grandi e ridurre la reattività dell'app.", @@ -270,7 +272,7 @@ "oauth_auto_register": "Registrazione automatica", "oauth_auto_register_description": "Automaticamente registra nuovi utenti dopo il login OAuth", "oauth_button_text": "Testo pulsante", - "oauth_client_secret_description": "Richiesto se PKCE (Proof Key for Code Exchange) non è supportato dal provider OAuth", + "oauth_client_secret_description": "Richiesto per client confidenziali o se PKCE (Proof Key for Code Exchange) non è supportato dal client pubblico.", "oauth_enable_description": "Login con OAuth", "oauth_mobile_redirect_uri": "URI di reindirizzamento per app mobile", "oauth_mobile_redirect_uri_override": "Sovrascrivi URI di reindirizzamento per app mobile", @@ -515,6 +517,7 @@ "all": "Tutti", "all_albums": "Tutti gli album", "all_people": "Tutte le persone", + "all_photos": "Tutte le foto", "all_videos": "Tutti i video", "allow_dark_mode": "Permetti Tema Scuro", "allow_edits": "Permetti modifiche", @@ -522,6 +525,9 @@ "allow_public_user_to_upload": "Permetti agli utenti pubblici di caricare", "allowed": "Consentito", "alt_text_qr_code": "Immagine QR", + "always_keep": "Mantieni sempre", + "always_keep_photos_hint": "Libera Spazio mantiene tutte le foto su questo dispositivo.", + "always_keep_videos_hint": "Libera Spazio mantiene tutti i video su questo dispositivo.", "anti_clockwise": "Senso anti-orario", "api_key": "Chiave API", "api_key_description": "Questo valore verrà mostrato una sola volta. Assicurati di copiarlo prima di chiudere la finestra.", @@ -566,6 +572,9 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Impostazioni del layout della griglia delle foto", "asset_list_settings_title": "Griglia foto", + "asset_not_found_on_device_android": "Risorsa non trovata sul dispositivo", + "asset_not_found_on_device_ios": "Risorsa non trovata sul dispositivo. Se stai usando iCloud, la risorsa potrebbe essere inaccessibile a causa di un file errato salvato su iCloud", + "asset_not_found_on_icloud": "Risorsa non trovata su iCloud. La risorsa potrebbe essere inaccessibile a causa di un file errato salvato su iCloud", "asset_offline": "Elemento Offline", "asset_offline_description": "Questa risorsa esterna non esiste più sul disco. Contatta il tuo amministratore di Immich per assistenza.", "asset_restored_successfully": "Risorsa ripristinata con successo", @@ -755,11 +764,12 @@ "cleanup_deleted_assets": "Spostate {count} risorse nel cestino", "cleanup_deleting": "Spostamento nel cestino...", "cleanup_found_assets": "Trovate {count} risorse già salvate", + "cleanup_found_assets_with_size": "Trovate {count} risorse salvate ({size})", "cleanup_icloud_shared_albums_excluded": "Gli Album Condivisi di iCloud sono esclusi dalla ricerca", - "cleanup_no_assets_found": "Nessuna risorsa già salvata corrisponde ai criteri richiesti", + "cleanup_no_assets_found": "Nessuna risorsa trovata con i criteri specificati. Libera Spazio può solo rimuovere le risorse che sono state salvate sul server", "cleanup_preview_title": "Risorse da rimuovere ({count})", - "cleanup_step3_description": "Ricerca foto e video che sono stati già salvati sul server e che corrispondono alle opzioni di ricerca", - "cleanup_step4_summary": "{count} risorse create prima del {date} sono in coda per la rimozione dal dispositivo", + "cleanup_step3_description": "Ricerca risorse già salvate sul server corrispondenti alle opzioni di ricerca.", + "cleanup_step4_summary": "{count} risorse (create prima del {date}) da rimuovere sul tuo dispositivo. Rimarrano comunque accessibili dall'app Immich.", "cleanup_trash_hint": "Per recuperare completamente lo spazio devi aprire l'app della galleria e svuotarne il cestino", "clear": "Pulisci", "clear_all": "Pulisci tutto", @@ -857,7 +867,7 @@ "custom_locale": "Localizzazione personalizzata", "custom_locale_description": "Formatta data e numeri in base alla lingua e al paese", "custom_url": "URL personalizzato", - "cutoff_date_description": "Rimuovi foto e video più vecchi del", + "cutoff_date_description": "Mantieni le foto fino al…", "cutoff_day": "{count, plural, one {giorno} other {giorni}}", "cutoff_year": "{count, plural, one {anno} other {anni}}", "daily_title_text_date": "E, dd MMM", @@ -1009,6 +1019,7 @@ "error_change_sort_album": "Errore nel cambiare l'ordine di degli album", "error_delete_face": "Errore nella rimozione del volto dalla risorsa", "error_getting_places": "Errore durante il recupero dei luoghi", + "error_loading_albums": "Errore nel caricamento degli album", "error_loading_image": "Errore nel caricamento dell'immagine", "error_loading_partners": "Errore durante il caricamento dei partner: {error}", "error_retrieving_asset_information": "Errore nel recuperare informazioni sull'elemento", @@ -1184,7 +1195,6 @@ "features": "Funzionalità", "features_in_development": "Funzionalità in fase di sviluppo", "features_setting_description": "Gestisci le funzionalità dell'app", - "file_name": "Nome file: {file_name}", "file_name_or_extension": "Nome file o estensione", "file_size": "Dimensione del file", "filename": "Nome file", @@ -1321,9 +1331,15 @@ "json_editor": "Modificatore JSON", "json_error": "JSON errore", "keep": "Mantieni", + "keep_albums": "Mantieni gli album", + "keep_albums_count": "{count} {count, plural, one {Album} other {Album}} mantenuti", "keep_all": "Tieni tutto", + "keep_description": "Scegli cosa rimane sul tuo dispositivo quando liberi spazio.", "keep_favorites": "Mantieni i favoriti", + "keep_on_device": "Mantieni sul dispositivo", + "keep_on_device_hint": "Seleziona le risorse da mantenere sul dispositivo", "keep_this_delete_others": "Tieni questo, elimina gli altri", + "keeping": "Mantieni: {items}", "kept_this_deleted_others": "Mantenuto questa risorsa ed {count, plural, one {eliminata # risorsa} other {eliminate # risorse}}", "keyboard_shortcuts": "Scorciatoie da tastiera", "language": "Lingua", @@ -1583,6 +1599,7 @@ "no_results_description": "Prova ad usare un sinonimo oppure una parola chiave più generica", "no_shared_albums_message": "Crea un album per condividere foto e video con le persone nella tua rete", "no_uploads_in_progress": "Nessun upload in corso", + "none": "Nulla", "not_allowed": "Non permesso", "not_available": "N/A", "not_in_any_album": "In nessun album", @@ -2117,6 +2134,8 @@ "skip_to_folders": "Salta alle cartelle", "skip_to_tags": "Salta alle etichette", "slideshow": "Presentazione", + "slideshow_repeat": "Ripeti presentazione", + "slideshow_repeat_description": "Ricomincia da capo quando la presentazione termina", "slideshow_settings": "Impostazioni presentazione", "sort_albums_by": "Ordina album per...", "sort_created": "Data creazione", @@ -2278,6 +2297,7 @@ "upload_details": "Dettagli di caricamento", "upload_dialog_info": "Vuoi fare il backup sul server delle risorse selezionate?", "upload_dialog_title": "Carica Risorsa", + "upload_error_with_count": "Invio in errore per {count, plural, one {# risorsa} other {# risorse}}", "upload_errors": "Caricamento completato con {count, plural, one {# errore} other {# errori}}, ricarica la pagina per vedere le risorse caricate.", "upload_finished": "Upload terminato", "upload_progress": "Rimanenti {remaining, number} - Processati {processed, number}/{total, number}", diff --git a/i18n/ja.json b/i18n/ja.json index 85d4183b28..b89e335004 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "レイアウト", "asset_list_settings_subtitle": "グリッドに関する設定", "asset_list_settings_title": "グリッド", + "asset_not_found_on_device_android": "デバイス上に写真/動画が見つかりません", + "asset_not_found_on_device_ios": "デバイス上に写真/動画が見つかりませんでした。iCloudを併せてご利用の場合は、iCloudのファイル保管方法に問題があり、アクセスできない可能性があります。", + "asset_not_found_on_icloud": "iCloud上の写真/動画が見つかりませんでした。", "asset_offline": "項目がオフラインです", "asset_offline_description": "この外部項目はディスク上にもうありません。Immichサーバーの管理者に連絡をしてください。", "asset_restored_successfully": "復元できました", @@ -865,6 +868,8 @@ "custom_locale_description": "言語と地域に基づいて日付と数値をフォーマットします", "custom_url": "カスタムURL", "cutoff_date_description": "写真を保持する期間:", + "cutoff_day": "{count, plural, one {(日)} other {(日)}}", + "cutoff_year": "{count, plural, one {年} other {年}}", "daily_title_text_date": "MM DD, EE", "daily_title_text_date_year": "yyyy MM DD, EE", "dark": "ダークモード", @@ -1190,7 +1195,6 @@ "features": "機能", "features_in_development": "開発中の機能", "features_setting_description": "アプリの機能を管理する", - "file_name": "ファイル名: {file_name}", "file_name_or_extension": "ファイル名または拡張子", "file_size": "ファイルサイズ", "filename": "ファイル名", @@ -1328,6 +1332,7 @@ "json_error": "JSONエラー", "keep": "保持", "keep_albums": "アルバムを保持", + "keep_albums_count": "{count}個のアルバムを残す", "keep_all": "全て保持", "keep_description": "ストレージを解放する際に、デバイスに残すものを選択できます。", "keep_favorites": "お気に入りを保持", @@ -1790,6 +1795,7 @@ "rating_clear": "評価を取り消す", "rating_count": "星{count, plural, one {#つ} other {#つ}}", "rating_description": "情報欄にEXIFの評価を表示", + "rating_set": "お気に入り度 {rating, plural, one {# ツ星} other {# ツ星}}", "reaction_options": "リアクションの選択", "read_changelog": "変更履歴を読む", "readonly_mode_disabled": "読み取り専用モード無効", @@ -2206,6 +2212,7 @@ "theme_setting_theme_subtitle": "テーマ設定", "theme_setting_three_stage_loading_subtitle": "三段階読み込みを有効にすると、パフォーマンスが改善する可能性がありますが、ネットワーク負荷が著しく増加します。", "theme_setting_three_stage_loading_title": "三段階読み込みをオンにする", + "then": "そのとき", "they_will_be_merged_together": "これらは一緒に統合されます", "third_party_resources": "サードパーティーリソース", "time": "時刻", @@ -2290,6 +2297,7 @@ "upload_details": "アップロードの詳細", "upload_dialog_info": "選択した項目のバックアップをしますか?", "upload_dialog_title": "アップロード", + "upload_error_with_count": "{count, plural, one {#個の写真/動画} other {#個の写真/動画}}についてアップロードエラーが発生しました", "upload_errors": "アップロードは{count, plural, one {#個} other {#個}}のエラーで完了しました、新しくアップロードされたアセットを見るにはページを更新してください。", "upload_finished": "アップロード完了", "upload_progress": "残り {remaining, number} - {processed, number}/{total, number} 処理済み", diff --git a/i18n/ka.json b/i18n/ka.json index ae367460c5..f8d98ed25c 100644 --- a/i18n/ka.json +++ b/i18n/ka.json @@ -16,6 +16,7 @@ "add_a_name": "დაამატე სახელი", "add_a_title": "დაასათაურე", "add_action": "დაამატე მოქმედება", + "add_assets": "რესურსის ატვირთვა", "add_birthday": "დაბადების დღის დამატება", "add_endpoint": "ბოლოწერტილის დამატება", "add_exclusion_pattern": "დაამატე გამონაკლისი ნიმუში", @@ -32,6 +33,7 @@ "add_to_album_bottom_sheet_already_exists": "{album}-ში უკვე არსებობს", "add_to_albums": "დაამატე ალბომებში", "add_to_albums_count": "დაამატე ალბომში ({count})", + "add_to_bottom_bar": "დამატება სად", "add_to_shared_album": "დაამატე საზიარო ალბომში", "add_url": "დაამატე URL", "added_to_archive": "დაარქივდა", @@ -86,6 +88,8 @@ "oauth_settings": "OAuth", "template_email_preview": "მინიატურა", "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_hardware_acceleration": "ჰარდვეარული ამაჩქარებელი", + "transcoding_policy": "ტრანსკოდირების პოლიტიკა", "transcoding_threads": "ნაკადები", "transcoding_tone_mapping": "ტონების ასახვა" }, diff --git a/i18n/ko.json b/i18n/ko.json index 07c7c21891..147ace091d 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -104,6 +104,8 @@ "image_preview_description": "메타데이터가 제거된 중간 크기 이미지. 기계 학습 또는 개별 항목을 표시할 때 사용됩니다.", "image_preview_quality_description": "미리보기의 품질을 1에서 100 사이로 설정합니다. 값을 높이면 품질이 좋아지지만 파일 크기가 커지고 앱 반응 속도가 느려질 수 있습니다. 너무 낮은 값은 기계 학습에 영향을 줄 수 있습니다.", "image_preview_title": "미리보기 설정", + "image_progressive": "점진적 로딩", + "image_progressive_description": "JPEG 이미지를 점진적으로 표시할 수 있게 단계적으로 인코딩합니다. WebP 이미지에는 영향이 없습니다.", "image_quality": "품질", "image_resolution": "해상도", "image_resolution_description": "해상도가 높으면 세부 정보가 보존되지만, 인코딩에 더 많은 시간이 소요되고 파일 크기가 커져 앱 반응 속도가 느려질 수 있습니다.", @@ -192,6 +194,7 @@ "maintenance_delete_backup_description": "이 파일은 영구적으로 삭제됩니다.", "maintenance_delete_error": "백업 삭제 실패.", "maintenance_restore_backup": "백업 복원", + "maintenance_restore_backup_description": "Immich가 삭제되고 선택한 백업에서 복원됩니다. 계속하기 전에 백업이 생성됩니다.", "maintenance_restore_backup_different_version": "이 백업은 다른 버전의 Immich에서 생성되었습니다!", "maintenance_restore_backup_unknown_version": "백업 버전을 확인할 수 없습니다.", "maintenance_restore_database_backup": "데이터베이스 백업 복원", @@ -269,7 +272,7 @@ "oauth_auto_register": "자동 등록", "oauth_auto_register_description": "OAuth 로그인 후 새 사용자를 자동으로 등록합니다.", "oauth_button_text": "버튼 텍스트", - "oauth_client_secret_description": "OAuth 제공자가 PKCE(Proof Key for Code Exchange, 코드 교환용 검증 키)를 지원하지 않는 경우 필요합니다.", + "oauth_client_secret_description": "비공개 클라이언트 또는 공개 클라이언트가 PKCE(Proof Key for Code Exchange, 코드 교환용 검증 키)를 지원하지 않는 경우 필요합니다.", "oauth_enable_description": "OAuth 로그인", "oauth_mobile_redirect_uri": "모바일 리다이렉트 URI", "oauth_mobile_redirect_uri_override": "모바일 리다이렉트 URI 오버라이드", @@ -352,7 +355,7 @@ "theme_settings": "테마 설정", "theme_settings_description": "Immich 웹 인터페이스를 사용자 정의합니다.", "thumbnail_generation_job": "섬네일 생성", - "thumbnail_generation_job_description": "각 항목 및 인물에 대해 크고 작은 썸네일, 흐릿한 썸네일 생성", + "thumbnail_generation_job_description": "각 항목 및 인물에 대해 크고 작은 썸네일, 흐릿한 섬네일 생성", "transcoding_acceleration_api": "가속 API", "transcoding_acceleration_api_description": "트랜스코딩 가속에 사용할 API를 지정합니다. 이 설정은 'best effort' 방식으로 동작하며, 실패 시 소프트웨어 트랜스코딩으로 전환됩니다. 하드웨어에 따라 VP9은 지원되지 않을 수 있습니다.", "transcoding_acceleration_nvenc": "NVENC (NVIDIA GPU 필요)", @@ -448,6 +451,9 @@ "admin_password": "관리자 비밀번호", "administration": "관리", "advanced": "고급", + "advanced_settings_clear_image_cache": "이미지 캐시 지우기", + "advanced_settings_clear_image_cache_error": "이미지 캐시 삭제 실패", + "advanced_settings_clear_image_cache_success": "{size}가 성공적으로 정리됨", "advanced_settings_enable_alternate_media_filter_subtitle": "이 옵션을 사용하면 동기화 중 미디어를 대체 기준으로 필터링할 수 있습니다. 앱이 모든 앨범을 제대로 감지하지 못할 때만 사용하세요.", "advanced_settings_enable_alternate_media_filter_title": "대체 기기 앨범 동기화 필터 사용 (실험적)", "advanced_settings_log_level_title": "로그 레벨: {level}", @@ -511,6 +517,7 @@ "all": "모두", "all_albums": "모든 앨범", "all_people": "모든 인물", + "all_photos": "모든 사진", "all_videos": "모든 동영상", "allow_dark_mode": "다크 모드 사용", "allow_edits": "편집자로 설정", @@ -518,6 +525,9 @@ "allow_public_user_to_upload": "모든 사용자의 업로드 허용", "allowed": "허용됨", "alt_text_qr_code": "QR 코드 이미지", + "always_keep": "항상 유지", + "always_keep_photos_hint": "이 기기에 모든 사진이 보관됩니다.", + "always_keep_videos_hint": "이 기기에 모든 동영상이 보관됩니다.", "anti_clockwise": "반시계 방향", "api_key": "API 키", "api_key_description": "이 값은 한 번만 표시됩니다. 창을 닫기 전 반드시 복사해주세요.", @@ -562,6 +572,9 @@ "asset_list_layout_sub_title": "레이아웃", "asset_list_settings_subtitle": "사진 배열 레이아웃 설정", "asset_list_settings_title": "사진 배열", + "asset_not_found_on_device_android": "기기에서 항목을 찾을 수 없음", + "asset_not_found_on_device_ios": "기기에서 항목을 찾을 수 없습니다. iCloud를 사용하는 경우 저장된 파일이 손상되었을 수 있습니다.", + "asset_not_found_on_icloud": "iCloud에서 항목을 찾을 수 없습니다. iCloud에 저장된 파일이 손상되었을 수 있습니다.", "asset_offline": "누락된 항목", "asset_offline_description": "디스크에서 항목을 더이상 찾을 수 없습니다. 서버 관리자에게 연락하세요.", "asset_restored_successfully": "항목이 복원되었습니다.", @@ -750,7 +763,14 @@ "cleanup_confirm_prompt_title": "이 기기에서 삭제하시겠습니까?", "cleanup_deleted_assets": "{count}개 항목 휴지통으로 이동됨", "cleanup_deleting": "휴지통으로 이동 중...", - "cleanup_found_assets": "백업된 {count}개의 항목을 찾았습니다", + "cleanup_found_assets": "백업된 {count}개의 항목 찾음", + "cleanup_found_assets_with_size": "백업된 {count}개의 항목 찾음 ({size})", + "cleanup_icloud_shared_albums_excluded": "iCloud의 공유 앨범은 스캔 대상에서 제외됩니다", + "cleanup_no_assets_found": "위 조건에 일치하는 항목이 없습니다. 저장 공간 확보 기능은 서버에 백업된 항목만 삭제할 수 있습니다.", + "cleanup_preview_title": "삭제 대상 항목 ({count})", + "cleanup_step3_description": "백업된 항목에서 날짜 및 보존 설정과 일치하는 항목을 스캔합니다.", + "cleanup_step4_summary": "({date} 이전에 생성된) {count} 항목이 로컬 기기에서 삭제됩니다. 사진은 Immich 앱에서 계속 액세스할 수 있습니다.", + "cleanup_trash_hint": "저장 공간을 완전히 확보하려면 시스템의 갤러리 앱을 열고 휴지통을 비우세요", "clear": "지우기", "clear_all": "모두 지우기", "clear_all_recent_searches": "검색 기록 전체 삭제", @@ -836,15 +856,20 @@ "created_at": "생성됨", "creating_linked_albums": "연결된 앨범 생성 중...", "crop": "자르기", + "crop_aspect_ratio_fixed": "고정", "crop_aspect_ratio_free": "직접 조절", "crop_aspect_ratio_original": "원본", "curated_object_page_title": "사물", "current_device": "현재 기기", "current_pin_code": "현재 PIN 코드", "current_server_address": "현재 서버 주소", + "custom_date": "날짜 선택", "custom_locale": "사용자 지정 로케일", "custom_locale_description": "언어 및 지역에 따른 날짜 및 숫자 형식 지정", "custom_url": "사용자 지정 URL", + "cutoff_date_description": "선택한 기간의 사진을 유지합니다…", + "cutoff_day": "{count, plural, one {일} other {일}}", + "cutoff_year": "{count, plural, one {년} other {년}}", "daily_title_text_date": "M월 d일 EEEE", "daily_title_text_date_year": "yyyy년 M월 d일 EEEE", "dark": "다크", @@ -926,6 +951,7 @@ "download_include_embedded_motion_videos": "모션 포토 영상", "download_include_embedded_motion_videos_description": "모션 포토에 포함된 동영상을 별도의 파일로 분리해 저장합니다.", "download_notfound": "다운로드할 수 없음", + "download_original": "원본 다운로드", "download_paused": "다운로드 일시 중지됨", "download_settings": "다운로드", "download_settings_description": "파일 다운로드 설정을 관리합니다.", @@ -935,6 +961,7 @@ "download_waiting_to_retry": "재시도 대기 중", "downloading": "다운로드", "downloading_asset_filename": "{filename} 다운로드 중...", + "downloading_from_icloud": "iCloud에서 다운로드 중", "downloading_media": "미디어 다운로드 중", "drop_files_to_upload": "아무 곳에나 파일을 드롭하여 업로드", "duplicates": "비슷한 항목", @@ -964,12 +991,14 @@ "edit_title": "제목 변경", "edit_user": "사용자 수정", "edit_workflow": "워크플로 편집", - "editor": "편집자", + "editor": "편집기", "editor_close_without_save_prompt": "변경 사항이 저장되지 않습니다.", "editor_close_without_save_title": "편집을 종료하시겠습니까?", "editor_confirm_reset_all_changes": "모든 수정사항을 초기화하시겠습니까?", "editor_flip_horizontal": "좌우반전", "editor_flip_vertical": "상하반전", + "editor_orientation": "방향", + "editor_reset_all_changes": "편집내용 초기화", "editor_rotate_left": "반시계 방향으로 90° 회전", "editor_rotate_right": "시계 방향으로 90° 회전", "email": "이메일", @@ -989,9 +1018,10 @@ "error": "오류", "error_change_sort_album": "앨범 표시 순서 변경 실패", "error_delete_face": "항목에서 얼굴 삭제 중 오류 발생", - "error_getting_places": "장소 로드 오류", - "error_loading_image": "이미지를 불러오는 중 오류 발생", - "error_loading_partners": "파트너 불러오기 실패: {error}", + "error_getting_places": "장소 로딩 오류", + "error_loading_albums": "앨범 로딩 오류", + "error_loading_image": "이미지 로딩 오류", + "error_loading_partners": "파트너 로딩 오류: {error}", "error_saving_image": "오류: {error}", "error_tag_face_bounding_box": "얼굴 태그 실패 - 얼굴의 위치를 가져올 수 없습니다.", "error_title": "오류 - 문제가 발생했습니다", @@ -1161,7 +1191,6 @@ "features": "기능", "features_in_development": "개발 중인 기능", "features_setting_description": "사진 및 동영상 관리 기능을 설정합니다.", - "file_name": "파일 이름: {file_name}", "file_name_or_extension": "파일명 또는 확장자", "file_size": "파일 크기", "filename": "파일명", @@ -1180,7 +1209,9 @@ "folders_feature_description": "파일 시스템의 사진과 동영상을 폴더 보기로 탐색합니다.", "forgot_pin_code_question": "PIN 번호를 잊어버렸나요?", "forward": "앞으로", - "free_up_space_description": "백업된 사진과 동영상을 기기의 휴지통으로 이동하여 저장 공간을 확보하세요. 원본 파일은 서버에 안전하게 보관됩니다", + "free_up_space": "저장 공간 확보", + "free_up_space_description": "백업된 사진과 동영상을 기기의 휴지통으로 이동하여 저장 공간을 확보하세요. 원본 파일은 서버에 안전하게 보관됩니다.", + "free_up_space_settings_subtitle": "기기의 저장 공간을 확보합니다.", "full_path": "전체 경로: {path}", "gcast_enabled": "구글 캐스트", "gcast_enabled_description": "이 기능은 Google의 외부 리소스를 사용합니다.", @@ -1296,9 +1327,15 @@ "json_editor": "JSON 편집기", "json_error": "JSON 오류", "keep": "유지", + "keep_albums": "앨범 유지", + "keep_albums_count": "{count}개의 {count, plural, one {앨범} other {앨범}} 유지", "keep_all": "모두 유지", + "keep_description": "저장 공간 확보시에 유지할 항목을 선택하세요.", "keep_favorites": "즐겨찾기 유지", + "keep_on_device": "기기에 유지", + "keep_on_device_hint": "이 기기에 유지할 항목을 선택합니다", "keep_this_delete_others": "이 항목은 유지하고 나머지는 삭제", + "keeping": "유지: {items}", "kept_this_deleted_others": "이 항목을 유지하고 {count, plural, one {#개의 항목} other {#개의 항목}}을 삭제함", "keyboard_shortcuts": "키보드 단축키", "language": "언어", @@ -1457,6 +1494,8 @@ "minimize": "최소화", "minute": "분", "minutes": "분", + "mirror_horizontal": "수평", + "mirror_vertical": "수직", "missing": "누락", "mobile_app": "모바일 앱", "mobile_app_download_onboarding_note": "다음 옵션 중 하나를 사용해 모바일 앱을 다운로드하세요.", @@ -1468,6 +1507,7 @@ "move_down": "아래로 이동", "move_off_locked_folder": "잠금 폴더에서 해제", "move_to": "다음으로 이동", + "move_to_device_trash": "기기의 휴지통으로 이동", "move_to_lock_folder_action_prompt": "잠금 폴더로 항목 {count}개 이동됨", "move_to_locked_folder": "잠금 폴더로 이동", "move_to_locked_folder_confirmation": "선택한 사진 또는 동영상이 모든 앨범에서 제거되며, 잠금 폴더에서만 볼 수 있습니다.", @@ -1507,6 +1547,7 @@ "next_memory": "다음 추억", "no": "아니요", "no_actions_added": "아직 추가된 작업이 없습니다", + "no_albums_found": "앨범이 없습니다.", "no_albums_message": "앨범을 생성하여 사진과 동영상을 정리하기", "no_albums_with_name_yet": "아직 해당하는 이름의 앨범이 없는 것 같습니다.", "no_albums_yet": "아직 앨범이 없는 것 같습니다.", @@ -1803,6 +1844,7 @@ "reset_sqlite_confirmation": "SQLite 데이터베이스를 초기화하시겠습니까? 데이터를 재동기화하려면 로그아웃 후 다시 로그인해야 합니다.", "reset_sqlite_success": "SQLite 데이터베이스를 초기화했습니다.", "reset_to_default": "기본값으로 복원", + "resolution": "해상도", "resolve_duplicates": "비슷한 항목 확인", "resolved_all_duplicates": "비슷한 항목을 모두 처리했습니다.", "restore": "복원", @@ -1827,6 +1869,7 @@ "saved_settings": "설정이 저장되었습니다.", "say_something": "댓글을 입력하세요", "scaffold_body_error_occurred": "오류가 발생했습니다.", + "scan": "스캔", "scan_all_libraries": "모든 라이브러리 스캔", "scan_library": "스캔", "scan_settings": "스캔 설정", @@ -2060,6 +2103,8 @@ "skip_to_folders": "폴더로 건너뛰기", "skip_to_tags": "태그로 건너뛰기", "slideshow": "슬라이드 쇼", + "slideshow_repeat": "슬라이드 쇼 반복", + "slideshow_repeat_description": "슬라이드 쇼가 끝나면 처음으로 되돌아갑니다", "slideshow_settings": "슬라이드 쇼 설정", "sort_albums_by": "다음으로 앨범 정렬...", "sort_created": "생성된 날짜", @@ -2191,6 +2236,7 @@ "unhide_person": "인물 숨김 해제", "unknown": "알 수 없음", "unknown_country": "알 수 없는 지역", + "unknown_date": "알 수 없는 날짜", "unknown_year": "알 수 없는 연도", "unlimited": "무제한", "unlink_motion_video": "모션 비디오 링크 해제", @@ -2219,6 +2265,7 @@ "upload_details": "업로드 상세", "upload_dialog_info": "선택한 항목을 서버에 백업하시겠습니까?", "upload_dialog_title": "항목 업로드", + "upload_error_with_count": "{count, plural, one {#개} other {#개}} 항목 업로드 실패", "upload_errors": "업로드가 완료되었습니다. 항목 {count, plural, one {#개} other {#개}}를 업로드하지 못했습니다. 업로드된 항목을 보려면 페이지를 새로고침하세요.", "upload_finished": "업로드 완료", "upload_progress": "전체 {total, number}개 중 {processed, number}개 완료, {remaining, number}개 대기 중", diff --git a/i18n/lt.json b/i18n/lt.json index be386755e7..2802bb58ab 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -1154,7 +1154,6 @@ "features": "Funkcijos", "features_in_development": "Kūrimo funkcijos", "features_setting_description": "Valdyti aplikacijos funkcijas", - "file_name": "Failo pavadinimas: {file_name}", "file_name_or_extension": "Failo pavadinimas arba plėtinys", "file_size": "Failo dydis", "filename": "Failopavadinimas", diff --git a/i18n/lv.json b/i18n/lv.json index e89efb0f1a..d9d2d52c6b 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -900,7 +900,6 @@ "favorites_page_no_favorites": "Nav atrasti iecienītākie faili", "features_in_development": "Izstrādes stadijā esošas funkcijas", "features_setting_description": "Lietotnes funkciju pārvaldība", - "file_name": "Faila nosaukums", "file_name_or_extension": "Faila nosaukums vai paplašinājums", "filename": "Faila nosaukums", "filetype": "Faila tips", @@ -1139,6 +1138,8 @@ "map_settings_only_show_favorites": "Rādīt tikai izlasi", "map_settings_theme_settings": "Kartes Dizains", "map_zoom_to_see_photos": "Attāliniet, lai redzētu fotoattēlus", + "mark_all_as_read": "Atzīmēt visus kā lasītus", + "marked_all_as_read": "Visi atzīmēti kā lasīti", "matches": "Atbilstības", "media_type": "Faila veids", "memories": "Atmiņas", @@ -1196,6 +1197,7 @@ "new_person": "Jauna persona", "new_pin_code": "Jaunais PIN kods", "new_timeline": "Jaunā laikjosla", + "new_update": "Pieejams atjauninājums", "new_user_created": "Izveidots jauns lietotājs", "new_version_available": "PIEEJAMA JAUNA VERSIJA", "next": "Nākamais", @@ -1487,7 +1489,7 @@ "select_album": "Izvēlies albumu", "select_album_cover": "Izvēlieties albuma vāciņu", "select_albums": "Izvēlies albumus", - "select_all_duplicates": "Atlasīt visus dublikātus", + "select_all_duplicates": "Atlasīt visus paturēšanai", "select_avatar_color": "Izvēlies avatāra krāsu", "select_face": "Izvēlies seju", "select_from_computer": "Izvēlēties no datora", @@ -1636,6 +1638,7 @@ "sort_title": "Nosaukums", "source": "Pirmkods", "stack": "Apvienot kaudzē", + "stack_duplicates": "Apvienot dublikātus kaudzē", "start": "Sākt", "start_date": "Sākuma datums", "start_date_before_end_date": "Sākuma datumam jābūt pirms beigu datuma", @@ -1718,6 +1721,7 @@ "unnamed_album": "Albums bez nosaukuma", "unsaved_change": "Nesaglabāta izmaiņa", "unselect_all": "Atcelt visu atlasi", + "unselect_all_duplicates": "Atlasīt visus dzēšanai", "unstack": "At-Stekot", "unsupported_field_type": "Nesatbalstīts lauka tips", "untitled_workflow": "Nenosaukta darba plūsma", diff --git a/i18n/mk.json b/i18n/mk.json index bb2df03977..b12fe7ca15 100644 --- a/i18n/mk.json +++ b/i18n/mk.json @@ -216,7 +216,6 @@ "favorite": "Омилено", "favorites": "Омилени", "features": "Функии", - "file_name": "Име на датотека", "filename": "Име на датотека", "filetype": "Тип на датотека", "filter_people": "Филтрирај луѓе", diff --git a/i18n/ml.json b/i18n/ml.json index 86fbbe2812..7fc4475bc5 100644 --- a/i18n/ml.json +++ b/i18n/ml.json @@ -1106,7 +1106,6 @@ "features": "ഫീച്ചറുകൾ", "features_in_development": "വികസിപ്പിച്ചുകൊണ്ടിരിക്കുന്ന ഫീച്ചറുകൾ", "features_setting_description": "ആപ്പ് ഫീച്ചറുകൾ കൈകാര്യം ചെയ്യുക", - "file_name": "ഫയലിന്റെ പേര്", "file_name_or_extension": "ഫയലിന്റെ പേര് അല്ലെങ്കിൽ എക്സ്റ്റൻഷൻ", "file_size": "ഫയൽ വലിപ്പം", "filename": "ഫയൽനാമം", diff --git a/i18n/mr.json b/i18n/mr.json index 1592a56ade..be0c96f9e9 100644 --- a/i18n/mr.json +++ b/i18n/mr.json @@ -1101,7 +1101,6 @@ "features": "वैशिष्ट्ये", "features_in_development": "विकासाधीन वैशिष्ट्ये", "features_setting_description": "अॅपची वैशिष्ट्ये व्यवस्थापित करा", - "file_name": "फाईल नाव", "file_name_or_extension": "फाईल नाव किंवा एक्स्टेंशन", "file_size": "फाइल साइज़", "filename": "फाइलनाव", diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index 77218cf29b..03cc792718 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -50,7 +50,7 @@ "add_exclusion_pattern_description": "Legg til ekskluderingsmønstre. Globbing med *, ** og ? støttes. For å ignorere alle filer i en hvilken som helst mappe som heter \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som slutter på \".tif\", bruk \"**/*.tif\". For å ignorere en absolutt filplassering, bruk \"/filsti/til/ignorer/**\".", "admin_user": "Administrasjonsbruker", "asset_offline_description": "Dette eksterne bibliotekselementet finnes ikke lenger på disk og har blitt flyttet til papirkurven. Hvis filen ble flyttet innad i biblioteket, se etter det tilsvarende elementet i tidslinjen din. For å gjenopprette elementet, vennligst sørg for at filstien under er tilgjengelig for Immich og skann biblioteket.", - "authentication_settings": "Godkjenningsinnstillinger", + "authentication_settings": "Godkjenninger", "authentication_settings_description": "Administrer passord, OAuth, og andre innstillinger for autentisering", "authentication_settings_disable_all": "Er du sikker på at du ønsker å deaktivere alle innloggingsmetoder? Innlogging vil bli fullstendig deaktivert.", "authentication_settings_reenable": "For å aktivere på nytt, bruk en Server Command.", @@ -65,7 +65,7 @@ "backup_onboarding_footer": "For mer informasjon om sikkerhetskopiering av Immich, se dokumentasjonen.", "backup_onboarding_parts_title": "En 3-2-1 sikkerhetskopi inkluderer:", "backup_onboarding_title": "Sikkerhetskopier", - "backup_settings": "Database-dump instillinger", + "backup_settings": "Database-dump", "backup_settings_description": "Håndter innstillinger for database-dump.", "cleared_jobs": "Ryddet opp jobber for: {job}", "config_set_by_file": "Konfigurasjonen er for øyeblikket satt av en konfigurasjonsfil", @@ -86,8 +86,8 @@ "export_config_as_json_description": "Last ned nåværende systemkonfigurasjon som en JSON fil", "external_libraries_page_description": "Administrering for eksterne bibliotek", "face_detection": "Ansiktsgjenkjennelse", - "face_detection_description": "Finn ansikter i bilder ved hjelp av maskinlæring. For videoer brukes bare miniatyrbildet. \"Alle\" går gjennom alle bilder (igjen). \"Tilbakestill\" fjerner all gjeldende ansiktsdata. \"Manglende\" legger til filer som ikke har blitt behandlet enda i køen. Oppdagede ansikter vil blir sendt til ansiktsgjenkjenning, og koblet til eksisterende eller nye personer.", - "facial_recognition_job_description": "Kobler oppdagede ansikt til personer. Dette utføres etter at ansiktssøk er fullført. \"Tilbakestill\" (om-)grupperer alle ansikt på nytt. \"Missing\" stiller opp ansikt som ikke har blitt tilordnet en person ennå.", + "face_detection_description": "Finn ansikter i bilder ved hjelp av maskinlæring. For videoer brukes bare miniatyrbildet. \"Alle\" går gjennom alle bilder (igjen). \"Tilbakestill\" fjerner all gjeldende ansiktsdata. \"Mangler\" legger til filer som ikke har blitt behandlet enda i køen. Oppdagede ansikter vil blir sendt til ansiktsgjenkjenning, og koblet til eksisterende eller nye personer.", + "facial_recognition_job_description": "Kobler oppdagede ansikt til personer. Dette utføres etter at ansiktssøk er fullført. \"Tilbakestill\" (om-)grupperer alle ansikt på nytt. \"Mangler\" stiller opp ansikt som ikke har blitt tilordnet en person ennå.", "failed_job_command": "Kommandoen {command} feilet for jobb: {job}", "force_delete_user_warning": "ADVARSEL: Dette vil umiddelbart fjerne brukeren og alle data. Dette kan ikke angres, og filene kan ikke gjenopprettes.", "image_format": "Format", @@ -265,7 +265,7 @@ "notification_email_test_email_sent": "En test-e-post er sendt til {email}. Vennligst sjekk innboksen din.", "notification_email_username_description": "Brukernavn som skal brukes ved autentisering med e-posts serveren", "notification_enable_email_notifications": "Aktiver e-postvarsler", - "notification_settings": "Innstillinger for varsler", + "notification_settings": "Varselinnstillinger", "notification_settings_description": "Administrer varselinnstillinger, inkludert e-post", "oauth_auto_launch": "Automatisk oppstart", "oauth_auto_launch_description": "Start OAuth-innloggingsflyten automatisk når du navigerer til innloggingssiden", @@ -378,7 +378,7 @@ "transcoding_constant_rate_factor": "Konstant ratefaktor (-crf)", "transcoding_constant_rate_factor_description": "Nivået på videokvaliteten. Typiske verdier er 23 for H.264, 28 for HEVC, 31 for VP9 og 35 for AV1. Lavere verdier gir bedre kvalitet, men større filstørrelser.", "transcoding_disabled_description": "Ikke transkoder noen videoer; dette kan føre til avspillingsproblemer på visse klienter", - "transcoding_encoding_options": "Kodek Alternativer", + "transcoding_encoding_options": "Kodek-alternativer", "transcoding_encoding_options_description": "Sett kodeks, oppløsning, kvalitet og andre valg for koding av videoer", "transcoding_hardware_acceleration": "Maskinvareakselerasjon", "transcoding_hardware_acceleration_description": "Eksperimentell: raskere transkoding, men kan ha lavere kvalitet ved samme bithastighet", @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Fordeling", "asset_list_settings_subtitle": "Innstillinger for layout av fotorutenett", "asset_list_settings_title": "Fotorutenett", + "asset_not_found_on_device_android": "Elementet ble ikke funnet på enheten", + "asset_not_found_on_device_ios": "Elementet ble ikke funnet på enheten. Hvis du bruker iCloud, kan elementet være utilgjengelig på grunn av en feilaktig fil er lagret i iCloud", + "asset_not_found_on_icloud": "Elementet ble ikke funnet på iCloud. Elementet kan være utilgjengelig fordi det ligger en feilaktig fil i iCloud", "asset_offline": "Fil utilgjengelig", "asset_offline_description": "Dette elementet er offline. Immich kan ikke aksessere dets lokasjon. Vennligst påse at elementet er tilgjengelig og skann så biblioteket på nytt.", "asset_restored_successfully": "Objekt(er) gjenopprettet", @@ -1005,13 +1008,13 @@ "empty_trash_confirmation": "Vil du virkelig Tømme søppelbøtta? Dette vil slette alle filene i søppelbøtta permanent fra Immich.\nDu kan ikke angre denne handlingen!", "enable": "Aktivere", "enable_backup": "Aktiver backup", - "enable_biometric_auth_description": "Skriv inn PINkoden for å aktivere biometrisk autentisering", + "enable_biometric_auth_description": "Skriv inn PIN-koden for å aktivere biometrisk autentisering", "enabled": "Aktivert", - "end_date": "Slutt dato", + "end_date": "Sluttdato", "enqueued": "I kø", "enter_wifi_name": "Skriv inn Wi-Fi navn", - "enter_your_pin_code": "Skriv inn din PIN kode", - "enter_your_pin_code_subtitle": "Skriv inn din PIN kode for å få tilgang til låst mappe", + "enter_your_pin_code": "Skriv inn din PIN-kode", + "enter_your_pin_code_subtitle": "Skriv inn din PIN-kode for å få tilgang til låst mappe", "error": "Feil", "error_change_sort_album": "Mislyktes ved endring av sorteringsrekkefølge på album", "error_delete_face": "Feil ved sletting av ansikt fra aktivia", @@ -1191,8 +1194,7 @@ "feature_photo_updated": "Fremhevet bilde oppdatert", "features": "Funksjoner", "features_in_development": "Funksjoner under utvikling", - "features_setting_description": "Administrerer funksjoner for appen", - "file_name": "Filnavn: {file_name}", + "features_setting_description": "Administrer funksjoner for appen", "file_name_or_extension": "Filnavn eller filtype", "file_size": "Filstørrelse", "filename": "Filnavn", @@ -1427,7 +1429,7 @@ "logs": "Logger", "longitude": "Lengdegrad", "look": "Se", - "loop_videos": "Gjenta Videoer", + "loop_videos": "Gjenta videoer", "loop_videos_description": "Aktiver for å automatisk loope en video i detaljeviseren.", "main_branch_warning": "Du bruker en utviklingsversjon; vi anbefaler på det sterkeste og bruke en utgitt versjon!", "main_menu": "Hovedmeny", @@ -1778,7 +1780,7 @@ "purchase_panel_title": "Hjelp prosjektet", "purchase_per_server": "For hver server", "purchase_per_user": "For hver bruker", - "purchase_remove_product_key": "Ta bor Produktnøkkel", + "purchase_remove_product_key": "Fjern produktnøkkel", "purchase_remove_product_key_prompt": "Vil du virkelig ta bort produktnøkkelen?", "purchase_remove_server_product_key": "Ta bort Server Produktnøkkel", "purchase_remove_server_product_key_prompt": "Vil du virkelig ta bort Server Produktnøkkelen?", @@ -1792,7 +1794,7 @@ "rating": "Stjernevurdering", "rating_clear": "Slett vurdering", "rating_count": "{count, plural, one {# sjerne} other {# stjerner}}", - "rating_description": "Hvis EXIF vurdering i informasjons panelet", + "rating_description": "Vis EXIF vurdering i informasjonspanel", "rating_set": "Vurdering satt til {rating, plural, one {# stjerne} other {# stjerner}}", "reaction_options": "Reaksjonsalternativer", "read_changelog": "Les endringslogg", @@ -1819,7 +1821,7 @@ "refreshes_every_file": "Oppdaterer alle filer", "refreshing_encoded_video": "Oppdaterer kodete video", "refreshing_faces": "Oppdaterer ansikter", - "refreshing_metadata": "Oppdaterer matadata", + "refreshing_metadata": "Oppdaterer metadata", "regenerating_thumbnails": "Regenererer miniatyrbilder", "remote": "Eksternt", "remote_assets": "Eksterne elementer", @@ -1910,7 +1912,7 @@ "search_by_ocr_example": "Latte", "search_camera_lens_model": "Søk etter objektivmodell...", "search_camera_make": "Søk etter kameramerke...", - "search_camera_model": "Søk etter kamera modell...", + "search_camera_model": "Søk etter kameramodell...", "search_city": "Søk etter by...", "search_country": "Søk etter land...", "search_filter_apply": "Aktiver filter", @@ -1951,7 +1953,7 @@ "search_rating": "Søk etter vurdering...", "search_result_page_new_search_hint": "Nytt søk", "search_settings": "Søke instillinger", - "search_state": "Søk etter stat...", + "search_state": "Søk etter fylke...", "search_suggestion_list_smart_search_hint_1": "Smartsøk er aktivert som standard, for å søke etter metadata bruk syntaksen ", "search_suggestion_list_smart_search_hint_2": "m:ditt-søkeord", "search_tags": "Søk tags...", @@ -2171,7 +2173,7 @@ "suggestions": "Forslag", "sunrise_on_the_beach": "Soloppgang på stranden", "support": "Støtte", - "support_and_feedback": "Støtte og Tilbakemelding", + "support_and_feedback": "Støtte og tilbakemelding", "support_third_party_description": "Immich-installasjonen din ble pakket av en tredjepart. Problemer du opplever kan være forårsaket av den pakken, så vennligst ta opp problemer med dem i første omgang ved å bruke koblingene nedenfor.", "swap_merge_direction": "Bytt retning på sammenslåingen", "sync": "Synkroniser", @@ -2295,6 +2297,7 @@ "upload_details": "Opplastingsdetaljer", "upload_dialog_info": "Vil du utføre backup av valgte element(er) til serveren?", "upload_dialog_title": "Last opp element", + "upload_error_with_count": "Opplastningsfeil for {count, plural, one {# element} other {# elementer}}", "upload_errors": "Opplasting fullført med {count, plural, one {# error} other {# errors}}, oppdater siden for å se nye opplastingsressurser.", "upload_finished": "Opplasting fullført", "upload_progress": "Gjenstående {remaining, number} – behandlet {processed, number}/{total, number}", @@ -2321,7 +2324,7 @@ "user_purchase_settings": "Kjøpe", "user_purchase_settings_description": "Administrer dine kjøp", "user_role_set": "Sett {user} som {role}", - "user_usage_detail": "Detaljer av brukers forbruk", + "user_usage_detail": "Detaljer av brukernes forbruk", "user_usage_stats": "Kontobruksstatistikk", "user_usage_stats_description": "Vis kontobruksstatistikk", "username": "Brukernavn", diff --git a/i18n/nl.json b/i18n/nl.json index 4bcd518f55..6d4f780dd3 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Fotoraster layout instellingen", "asset_list_settings_title": "Fotoraster", + "asset_not_found_on_device_android": "Item niet gevonden op apparaat", + "asset_not_found_on_device_ios": "Item niet gevonden op apparaat. Wanneer je iCloud gebruikt, kan het item niet toegankelijk zijn door een slecht bestand in iCloud", + "asset_not_found_on_icloud": "Item niet gevonden in iCloud. Het item kan ontoegankelijk zijn door een slecht bestand op iCloud", "asset_offline": "Item offline", "asset_offline_description": "Dit externe item is niet meer op de schijf te vinden. Neem contact op met de Immich beheerder voor hulp.", "asset_restored_successfully": "Item succesvol hersteld", @@ -866,7 +869,7 @@ "custom_url": "Aangepaste URL", "cutoff_date_description": "Bewaar foto's van de laatste…", "cutoff_day": "{count, plural, one {dag} other {dagen}}", - "cutoff_year": "{count, plural, one {jaar} other {jaren}}", + "cutoff_year": "{count, plural, one {jaar} other {jaar}}", "daily_title_text_date": "E dd MMM", "daily_title_text_date_year": "E dd MMM yyyy", "dark": "Donker", @@ -1192,7 +1195,6 @@ "features": "Functies", "features_in_development": "Functies in ontwikkeling", "features_setting_description": "Beheer de app functies", - "file_name": "Bestandsnaam: {file_name}", "file_name_or_extension": "Bestandsnaam of extensie", "file_size": "Bestandsgrootte", "filename": "Bestandsnaam", @@ -2295,6 +2297,7 @@ "upload_details": "Uploaddetails", "upload_dialog_info": "Wil je een backup maken van de geselecteerde item(s) op de server?", "upload_dialog_title": "Item uploaden", + "upload_error_with_count": "Upload fout voor {count, plural, one {# item} other {# items}}", "upload_errors": "Upload voltooid met {count, plural, one {# fout} other {# fouten}}, vernieuw de pagina om de nieuwe items te zien.", "upload_finished": "Uploaden is voltooid", "upload_progress": "Resterend {remaining, number} - Verwerkt {processed, number}/{total, number}", diff --git a/i18n/package.json b/i18n/package.json index efb0458819..cb3560bea1 100644 --- a/i18n/package.json +++ b/i18n/package.json @@ -1,6 +1,6 @@ { "name": "immich-i18n", - "version": "2.5.2", + "version": "2.5.5", "private": true, "scripts": { "format": "prettier --check .", diff --git a/i18n/pl.json b/i18n/pl.json index 1d0f564c89..37ea94d2c9 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -272,7 +272,7 @@ "oauth_auto_register": "Automatyczna rejestracja", "oauth_auto_register_description": "Automatycznie rejestruj nowych użytkowników po zalogowaniu się za pomocą protokołu OAuth", "oauth_button_text": "Tekst na przycisku", - "oauth_client_secret_description": "Wymagane jeżeli PKCE (Proof Key for Code Exchange) nie jest wspierane przez dostawcę OAuth", + "oauth_client_secret_description": "Wymagane dla poufnego klienta lub jeśli PKCE (Proof Key for Code Exchange) nie jest obsługiwane dla klienta publicznego.", "oauth_enable_description": "Loguj się za pomocą OAuth", "oauth_mobile_redirect_uri": "Mobilny adres zwrotny", "oauth_mobile_redirect_uri_override": "Zapasowy URI przekierowania mobilnego", @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Układ", "asset_list_settings_subtitle": "Ustawienia układu siatki zdjęć", "asset_list_settings_title": "Siatka Zdjęć", + "asset_not_found_on_device_android": "Nie znaleziono zasobu na urządzeniu", + "asset_not_found_on_device_ios": "Nie znaleziono zasobu na urządzeniu. Jeśli korzystasz z usługi iCloud, zasób może być niedostępny z powodu uszkodzonego pliku przechowywanego w usłudze iCloud", + "asset_not_found_on_icloud": "Nie znaleziono zasobu w usłudze iCloud. Zasób może być niedostępny z powodu uszkodzonego pliku przechowywanego w usłudze iCloud", "asset_offline": "Zasób niedostępny", "asset_offline_description": "Ten zewnętrzny zasób nie jest już dostępny na dysku. Aby uzyskać pomoc, skontaktuj się z administratorem Immich.", "asset_restored_successfully": "Zasób został pomyślnie przywrócony", @@ -864,7 +867,7 @@ "custom_locale": "Niestandardowy Region", "custom_locale_description": "Formatuj daty i liczby na podstawie języka i regionu", "custom_url": "Niestandardowy URL", - "cutoff_date_description": "Przechowuj zdjęcia z ostatnich…", + "cutoff_date_description": "Zachowaj zdjęcia z ostatnich…", "cutoff_day": "{count, plural, one {dzień} other {dni}}", "cutoff_year": "{count, plural, one {rok} few {lata} other {lat}}", "daily_title_text_date": "E, dd MMM", @@ -1192,7 +1195,6 @@ "features": "Funkcje", "features_in_development": "Funkcje w fazie rozwoju", "features_setting_description": "Zarządzaj funkcjami aplikacji", - "file_name": "Nazwa pliku: {file_name}", "file_name_or_extension": "Nazwie lub rozszerzeniu pliku", "file_size": "Rozmiar pliku", "filename": "Nazwa pliku", @@ -1330,14 +1332,14 @@ "json_error": "Błąd JSON", "keep": "Zachowaj", "keep_albums": "Zachowaj albumy", - "keep_albums_count": "Przechowano {count} {count, plural, one {album} few {albumy} other {albumów}}", + "keep_albums_count": "Zachowuję {count} {count, plural, one {album} few {albumy} other {albumów}}", "keep_all": "Zachowaj wszystko", "keep_description": "Wybierz, co zachować na Twoim urządzeniu przy zwalnianiu miejsca.", "keep_favorites": "Zachowaj ulubione", "keep_on_device": "Zachowaj na urządzeniu", - "keep_on_device_hint": "Wybierz , co zachować na tym urządzeniu", + "keep_on_device_hint": "Wybierz elementy, które chcesz zachować na tym urządzeniu", "keep_this_delete_others": "Zachowaj to, usuń pozostałe", - "keeping": "Przechowano:{items}", + "keeping": "Zachowuję:{items}", "kept_this_deleted_others": "Zachowano ten zasób i usunięto {count, plural, one {#zasób} other {#zasoby}}", "keyboard_shortcuts": "Skróty klawiaturowe", "language": "Język", @@ -1597,7 +1599,7 @@ "no_results_description": "Spróbuj użyć synonimu lub bardziej ogólnego słowa kluczowego", "no_shared_albums_message": "Stwórz album aby udostępnić zdjęcia i filmy osobom w Twojej sieci", "no_uploads_in_progress": "Brak przesyłań w toku", - "none": "Pusto", + "none": "Żadne", "not_allowed": "Niedozwolone", "not_available": "Nie dotyczy", "not_in_any_album": "Bez albumu", @@ -2093,7 +2095,7 @@ "sharing": "Udostępnianie", "sharing_enter_password": "Wprowadź hasło, aby wyświetlić tę stronę.", "sharing_page_album": "Udostępnione albumy", - "sharing_page_description": "Twórz wspóldzielone albumy, aby udostępniać zdjęcia i filmy osobom w sieci.", + "sharing_page_description": "Twórz współdzielone albumy, aby udostępniać zdjęcia i filmy osobom w twojej sieci.", "sharing_page_empty_list": "PUSTA LISTA", "sharing_sidebar_description": "Wyświetl link do udostępniania na pasku bocznym", "sharing_silver_appbar_create_shared_album": "Utwórz współdzielony album", @@ -2295,6 +2297,7 @@ "upload_details": "Szczegóły przesyłania", "upload_dialog_info": "Czy chcesz wykonać kopię zapasową wybranych zasobów na serwerze?", "upload_dialog_title": "Prześlij Zasób", + "upload_error_with_count": "Błąd przesyłania dla {count, plural, one {# zasobu} other {# zasobów}}", "upload_errors": "Przesyłanie zakończone z {count, plural, one {# błędem} other {# błędami}}. Odśwież stronę, aby zobaczyć nowo przesłane zasoby.", "upload_finished": "Przesyłanie zakończone", "upload_progress": "Pozostałe {remaining, number} - Przetworzone {processed, number}/{total, number}", diff --git a/i18n/pt.json b/i18n/pt.json index 67b2471415..fdf3613d1e 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -104,6 +104,8 @@ "image_preview_description": "Imagem de tamanho médio sem metadados, utilizada ao visualizar um único ficheiro e pela aprendizagem de máquina", "image_preview_quality_description": "Qualidade de pré-visualização de 1 a 100. Maior é melhor, mas produz ficheiros maiores e pode reduzir a capacidade de resposta da aplicação. Definir um valor demasiado baixo pode afetar a qualidade da aprendizagem de máquina.", "image_preview_title": "Definições de Pré-visualização", + "image_progressive": "Progressivo", + "image_progressive_description": "Codificar imagens JPEG de forma progressiva para exibição com carregamento gradual. Não tem efeito em imagens WebP.", "image_quality": "Qualidade", "image_resolution": "Resolução", "image_resolution_description": "Resoluções mais altas podem ajudar a preservar mais detalhes mas demoram mais a codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", @@ -188,10 +190,21 @@ "machine_learning_smart_search_enabled": "Ativar a Pesquisa Inteligente", "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para Pesquisa Inteligente.", "machine_learning_url_description": "A URL do servidor de aprendizagem de máquina. Se for fornecido mais do que um URL, cada servidor será testado, um a um, até um deles responder com sucesso, por ordem do primeiro ao último. Servidores que não responderem serão temporariamente ignorados até voltarem a estar online.", + "maintenance_delete_backup": "Eliminar Cópia de Segurança", + "maintenance_delete_backup_description": "Este ficheiro irá ser apagado para sempre.", + "maintenance_delete_error": "Ocorreu um erro ao eliminar a cópia de segurança.", + "maintenance_restore_backup": "Restaurar Cópia de Segurança", + "maintenance_restore_backup_description": "O Immich irá ser apagado e de seguida restaurado a partir da cópia de segurança selecionada. Irá ser criada uma cópia de segurança antes de continuar.", + "maintenance_restore_backup_different_version": "Esta cópia de segurança foi criada com uma versão diferente do Immich!", + "maintenance_restore_backup_unknown_version": "Não foi possível determinar a versão da cópia de segurança.", + "maintenance_restore_database_backup": "Restaurar cópia de seguraça da base de dados", + "maintenance_restore_database_backup_description": "Reverter para um estado anterior da base de dados utilizando um ficheiro de cópia de segurança", "maintenance_settings": "Manutenção", "maintenance_settings_description": "Colocar o Immich no modo de manutenção.", - "maintenance_start": "Iniciar modo de manutenção", + "maintenance_start": "Aternar para o modo de manutenção", "maintenance_start_error": "Ocorreu um erro ao iniciar o modo de manutenção.", + "maintenance_upload_backup": "Carregar ficheiro de cópia de segurança da base de dados", + "maintenance_upload_backup_error": "Não foi possível carregar cópia de segurança. É um ficheiro .sql/.sql.gz?", "manage_concurrency": "Gerir simultaneidade", "manage_concurrency_description": "Navegar para a página das tarefas para gerir as tarefas em simultâneo", "manage_log_settings": "Gerir definições de registo", @@ -259,7 +272,7 @@ "oauth_auto_register": "Registo automático", "oauth_auto_register_description": "Registar automaticamente novos utilizadores após iniciarem sessão com o OAuth", "oauth_button_text": "Texto do botão", - "oauth_client_secret_description": "Obrigatório se PKCE (Proof Key for Code Exchange) não for suportado pelo provedor OAuth", + "oauth_client_secret_description": "Obrigatório para o cliente confidencial, ou se a PKCE (Proof Key for Code Exchange) não for suportada para cliente público.", "oauth_enable_description": "Iniciar sessão com o OAuth", "oauth_mobile_redirect_uri": "URI de redirecionamento móvel", "oauth_mobile_redirect_uri_override": "Substituição de URI de redirecionamento móvel", @@ -438,6 +451,9 @@ "admin_password": "Palavra-passe do administrador", "administration": "Administração", "advanced": "Avançado", + "advanced_settings_clear_image_cache": "Limpar a Cache de Imagens", + "advanced_settings_clear_image_cache_error": "Ocorreu um erro ao limpar a cache de imagens", + "advanced_settings_clear_image_cache_success": "Limpeza concluída com sucesso {size}", "advanced_settings_enable_alternate_media_filter_subtitle": "Utilize esta definição para filtrar ficheiros durante a sincronização baseada em critérios alternativos. Utilize apenas se a aplicação estiver com problemas a detetar todos os álbuns.", "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Utilizar um filtro alternativo de sincronização de álbuns em dispositivos", "advanced_settings_log_level_title": "Nível de registo: {level}", @@ -501,6 +517,7 @@ "all": "Todos", "all_albums": "Todos os álbuns", "all_people": "Todas as pessoas", + "all_photos": "Todas as fotos", "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", @@ -508,6 +525,9 @@ "allow_public_user_to_upload": "Permitir que utilizadores públicos façam carregamentos", "allowed": "Permitido", "alt_text_qr_code": "Imagem do código QR", + "always_keep": "Manter sempre", + "always_keep_photos_hint": "Libertar Espaço irá manter todas as fotos neste dispositivo.", + "always_keep_videos_hint": "Libertar Espaço irá manter todos os vídeos neste dispositivo.", "anti_clockwise": "Sentido anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", @@ -552,6 +572,9 @@ "asset_list_layout_sub_title": "Disposição", "asset_list_settings_subtitle": "Configurações de disposição da grade de fotos", "asset_list_settings_title": "Grade de fotos", + "asset_not_found_on_device_android": "Ficheiro não encontrado no dispositivo", + "asset_not_found_on_device_ios": "Ficheiro não encontrado no dispositivo. Se estiver a utilizar o iCloud, o ficheiro pode estar inacessível devido a um ficheiro corrompido armazenado no iCloud", + "asset_not_found_on_icloud": "Ficheiro não encontrado no iCloud. Este pode estar inacessível devido a um ficheiro corrompido armazenado no iCloud", "asset_offline": "Ficheiro Indisponível", "asset_offline_description": "Este ficheiro externo deixou de estar disponível no disco. Contacte o seu administrador do Immich para obter ajuda.", "asset_restored_successfully": "FIcheiro restaurado com sucesso", @@ -603,7 +626,7 @@ "backup_album_selection_page_select_albums": "Selecione Álbuns", "backup_album_selection_page_selection_info": "Informações da Seleção", "backup_album_selection_page_total_assets": "Total de ficheiros únicos", - "backup_albums_sync": "Cópia de segurança de sincronização de álbuns", + "backup_albums_sync": "Cópia de Segurança de Sincronização de Álbuns", "backup_all": "Tudo", "backup_background_service_backup_failed_message": "Ocorreu um erro ao efetuar cópia de segurança dos ficheiros. A tentar de novo…", "backup_background_service_complete_notification": "Cópia de conteúdos concluída", @@ -741,11 +764,12 @@ "cleanup_deleted_assets": "{count} ficheiro(s) foram movidos para a reciclagem do dispositivo", "cleanup_deleting": "A mover para a reciclagem...", "cleanup_found_assets": "Foram encontrados {count} ficheiro(s) com cópias de segurança", + "cleanup_found_assets_with_size": "Foram encontrados {count} ficheiros com cópia de segurança ({size})", "cleanup_icloud_shared_albums_excluded": "Álbuns Partilhados do iCloud serão excluídos da pesquisa", - "cleanup_no_assets_found": "Nenhum ficheiro de cópia de segurança encontrado que siga os seus critérios", + "cleanup_no_assets_found": "Nenhum ficheiro encontrado que siga os critérios acima. Libertar Espaço apenas pode remover ficheiros que tenham sido copiados para o servidor", "cleanup_preview_title": "Ficheiros a serem removidos ({count})", - "cleanup_step3_description": "Procurar por fotos e vídeos que tenham sido copiados para o servidor com a data limite e as opções de filtro selecionadas", - "cleanup_step4_summary": "{count} ficheiros criados antes de {date} estão em espera para serem removidos do seu dispositivo", + "cleanup_step3_description": "Procurar por ficheiros no servidor que sigam os seus critérios de data e se serão mantidos.", + "cleanup_step4_summary": "{count} ficheiros (criados antes de {date}) para remover do seu dispositivo local. As fotos irão manter-se acessíveis através da aplicação do Immich.", "cleanup_trash_hint": "Para recuperar por completo o espaço de armazenamento, abra a aplicação da galeria do sistema e esvazie a reciclagem", "clear": "Limpar", "clear_all": "Limpar tudo", @@ -843,7 +867,7 @@ "custom_locale": "Localização Personalizada", "custom_locale_description": "Formatar datas e números baseados na língua e na região", "custom_url": "URL personalizado", - "cutoff_date_description": "Remover fotos e vídeos anteriores a", + "cutoff_date_description": "Manter fotos dos últimos…", "cutoff_day": "{count, plural, one {dia} other {dias}}", "cutoff_year": "{count, plural, one {ano} other {anos}}", "daily_title_text_date": "E, dd MMM", @@ -995,11 +1019,14 @@ "error_change_sort_album": "Ocorreu um erro ao mudar a ordem de exibição", "error_delete_face": "Falha ao remover rosto do ficheiro", "error_getting_places": "Erro ao obter locais", + "error_loading_albums": "Ocorreu um erro ao carregar os álbuns", "error_loading_image": "Erro ao carregar a imagem", "error_loading_partners": "Erro ao carregar parceiros: {error}", + "error_retrieving_asset_information": "Ocorreu um erro ao carregar as informações do ficheiro", "error_saving_image": "Erro: {error}", "error_tag_face_bounding_box": "Erro ao marcar o rosto - não foi possível localizar o rosto", "error_title": "Erro - Algo correu mal", + "error_while_navigating": "Ocorreu um erro ao navegar para o ficheiro", "errors": { "cannot_navigate_next_asset": "Não foi possível navegar para o próximo ficheiro", "cannot_navigate_previous_asset": "Não foi possível navegar para o ficheiro anterior", @@ -1168,7 +1195,6 @@ "features": "Funcionalidades", "features_in_development": "Funcionalidades em Desenvolvimento", "features_setting_description": "Configurar as funcionalidades da aplicação", - "file_name": "Nome do ficheiro: {file_name}", "file_name_or_extension": "Nome do ficheiro ou extensão", "file_size": "Tamanho do ficheiro", "filename": "Nome do ficheiro", @@ -1188,7 +1214,7 @@ "forgot_pin_code_question": "Esqueceu-se do seu PIN?", "forward": "Para a frente", "free_up_space": "Libertar Espaço", - "free_up_space_description": "Mover fotos e vídeos que tenham sido copiados para o servidor para a reciclagem do seu dispositivo para libertar espaço. As cópias no servidor mantêm-se seguras", + "free_up_space_description": "Mover fotos e vídeos que tenham sido copiados para o servidor para a reciclagem do seu dispositivo para libertar espaço. As cópias no servidor mantêm-se seguras.", "free_up_space_settings_subtitle": "Libertar espaço no dispositivo", "full_path": "Caminho completo: {path}", "gcast_enabled": "Google Cast", @@ -1305,9 +1331,15 @@ "json_editor": "Editor JSON", "json_error": "Erro JSON", "keep": "Manter", + "keep_albums": "Manter álbuns", + "keep_albums_count": "A manter {count} {count, plural, one {álbum} other {álbuns}}", "keep_all": "Manter Todos", + "keep_description": "Escolha o que fica no seu dispositivo quando liberta espaço.", "keep_favorites": "Manter favoritos", + "keep_on_device": "Manter no dispositivo", + "keep_on_device_hint": "Selecionar itens para manter neste dispositivo", "keep_this_delete_others": "Manter este ficheiro, eliminar os outros", + "keeping": "A manter: {items}", "kept_this_deleted_others": "Foi mantido ficheiro e {count, plural, one {eliminado # outro} other {eliminados # outros}}", "keyboard_shortcuts": "Atalhos do teclado", "language": "Idioma", @@ -1401,10 +1433,28 @@ "loop_videos_description": "Ativar para repetir os vídeos automaticamente durante a exibição.", "main_branch_warning": "Está a usar uma versão de desenvolvimento; recomendamos vivamente que use uma versão de lançamento!", "main_menu": "Menu Principal", + "maintenance_action_restore": "A Restaurar Base de Dados", "maintenance_description": "O Immich foi colocado em modo de manutenção.", "maintenance_end": "Desativar modo de manutenção", "maintenance_end_error": "Ocorreu um erro ao desativar o modo de manutenção.", "maintenance_logged_in_as": "Sessão iniciada como {user}", + "maintenance_restore_from_backup": "Restaurar a partir de uma cópia de segurança", + "maintenance_restore_library": "Restaurar a Sua Biblioteca", + "maintenance_restore_library_confirm": "Se isto parecer correto, continue para restaurar uma cópia de segurança!", + "maintenance_restore_library_description": "A Restaurar Base de Dados", + "maintenance_restore_library_folder_has_files": "{folder} tem {count} pasta(s)", + "maintenance_restore_library_folder_no_files": "{folder} tem ficheiros em falta!", + "maintenance_restore_library_folder_pass": "leitura e escrita possível", + "maintenance_restore_library_folder_read_fail": "leitura impossível", + "maintenance_restore_library_folder_write_fail": "escrita impossível", + "maintenance_restore_library_hint_missing_files": "Pode ter ficheiros importantes em falta", + "maintenance_restore_library_hint_regenerate_later": "Pode regenerá-las mais tarde nas definições", + "maintenance_restore_library_hint_storage_template_missing_files": "Está a utilizar um modelo de armazenamento? Pode ter ficheiros em falta", + "maintenance_restore_library_loading": "A carregar verificações de integradade e heurísticas…", + "maintenance_task_backup": "A criar uma cópia de segurança da base de dados existente…", + "maintenance_task_migrations": "A migrar base de dados…", + "maintenance_task_restore": "A restaurar a cópia de segurança selecionada…", + "maintenance_task_rollback": "Não foi possível restaurar, a reverter para o ponto de restauro…", "maintenance_title": "Temporariamente Indisponível", "make": "Marca", "manage_geolocation": "Gerir localização", @@ -1519,11 +1569,12 @@ "next_memory": "Próxima memória", "no": "Não", "no_actions_added": "Ainda não foram adicionadas ações", + "no_albums_found": "Nenhum álbum encontrado", "no_albums_message": "Crie um álbum para organizar as suas fotos e vídeos", "no_albums_with_name_yet": "Parece que ainda não tem nenhum álbum com este nome.", "no_albums_yet": "Parece que ainda não tem nenhum álbum.", "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", - "no_assets_message": "FAÇA CLIQUE PARA CARREGAR A SUA PRIMEIRA FOTO", + "no_assets_message": "Clique para carregar a sua primeira foto", "no_assets_to_show": "Não há ficheiros para exibir", "no_cast_devices_found": "Nenhum dispositivo de transmissão encontrado", "no_checksum_local": "Sem cálculo de verificação disponível - não pode capturar conteúdos locais", @@ -1548,6 +1599,7 @@ "no_results_description": "Tente um sinónimo ou uma palavra-chave mais comum", "no_shared_albums_message": "Crie um álbum para partilhar fotos e vídeos com pessoas na sua rede", "no_uploads_in_progress": "Nenhum carregamento em curso", + "none": "Nenhum", "not_allowed": "Não permitido", "not_available": "N/A", "not_in_any_album": "Não está em nenhum álbum", @@ -1877,6 +1929,7 @@ "search_filter_media_type_title": "Selecione o tipo do ficheiro", "search_filter_ocr": "Pesquisar por OCR", "search_filter_people_title": "Selecionar pessoas", + "search_filter_star_rating": "Classificação", "search_for": "Pesquisar por", "search_for_existing_person": "Pesquisar por pessoas existentes", "search_no_more_result": "Sem mais resultados", @@ -2081,6 +2134,8 @@ "skip_to_folders": "Saltar para pastas", "skip_to_tags": "Saltar para as etiquetas", "slideshow": "Apresentação", + "slideshow_repeat": "Repetir apresentação de diapositivos", + "slideshow_repeat_description": "Repetir do inicio quando a apresentação acabar", "slideshow_settings": "Definições de apresentação", "sort_albums_by": "Ordenar álbuns por...", "sort_created": "Data de criação", @@ -2157,6 +2212,7 @@ "theme_setting_theme_subtitle": "Escolha a configuração do tema da aplicação", "theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios pode aumentar o desempenho do carregamento, mas causa uma carga de rede significativamente maior", "theme_setting_three_stage_loading_title": "Habilitar carregamento em três estágios", + "then": "Depois", "they_will_be_merged_together": "Eles serão unidos", "third_party_resources": "Recursos de terceiros", "time": "Hora", @@ -2212,6 +2268,7 @@ "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", "unknown_country": "País desconhecido", + "unknown_date": "Data desconhecida", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", "unlink_motion_video": "Remover relação com video animado", @@ -2240,6 +2297,7 @@ "upload_details": "Detalhes do Carregamento", "upload_dialog_info": "Deseja realizar uma cópia de segurança dos ficheiros selecionados para o servidor?", "upload_dialog_title": "Enviar ficheiro", + "upload_error_with_count": "Erro ao carregar {count, plural, one {# ficheiro} other {# ficheiros}}", "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos ficheiros enviados.", "upload_finished": "Carregamento acabado", "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", @@ -2254,7 +2312,7 @@ "url": "URL", "usage": "Utilização", "use_biometric": "Utilizar dados biométricos", - "use_current_connection": "usar conexão atual", + "use_current_connection": "Utilizar a ligação atual", "use_custom_date_range": "Utilizar um intervalo de datas personalizado", "user": "Utilizador", "user_has_been_deleted": "Este utilizador for eliminado.", diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index 452784d591..6a98ddf8e1 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -77,7 +77,7 @@ "confirm_user_pin_code_reset": "Tem certeza de que deseja redefinir o código PIN do usuário {user}?", "copy_config_to_clipboard_description": "Copiar as configurações do sistema como um objeto JSON para a área de transferência", "create_job": "Criar tarefa", - "cron_expression": "Expressão CRON", + "cron_expression": "Expressão cron", "cron_expression_description": "Defina o intervalo de análise no formato Cron. Para mais informações, por favor veja o Crontab Guru", "cron_expression_presets": "Sugestões de expressão Cron", "disable_login": "Desabilitar login", @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Configurações de layout da grade de fotos", "asset_list_settings_title": "Grade de Fotos", + "asset_not_found_on_device_android": "Arquivo não encontrado no dispositivo", + "asset_not_found_on_device_ios": "Arquivo não encontrado no dispositivo. Se estiver usando o iCloud, o arquivo pode estar inacessível devido a um arquivo corrompido armazenado no iCloud", + "asset_not_found_on_icloud": "Arquivo não encontrado no iCloud. o arquivo pode estar inacessível devido a um arquivo corrompido armazenado no iCloud", "asset_offline": "Arquivo indisponível", "asset_offline_description": "Este arquivo externo não está mais disponível. Contate seu administrador do Immich para obter ajuda.", "asset_restored_successfully": "Arquivo restaurado", @@ -761,11 +764,12 @@ "cleanup_deleted_assets": "{count} mídias movidas para a lixeira do dispositivo", "cleanup_deleting": "Movendo para a lixeira...", "cleanup_found_assets": "Encontrados {count} arquivos com backup", + "cleanup_found_assets_with_size": "Foram encontrados {count} arquivos com backup ({size})", "cleanup_icloud_shared_albums_excluded": "Álbuns compartilhados do iCloud não serão incluídos", - "cleanup_no_assets_found": "Não foram encontrados arquivos que correspondam aos seus critérios", + "cleanup_no_assets_found": "Não foram encontrados arquivos que correspondam aos seus critérios. Liberar Espaço só pode remover arquivos que foram copiados para o servidor", "cleanup_preview_title": "Remover {count} arquivos", - "cleanup_step3_description": "Procurar por fotos e vídeos que já tem o backup feito no servidor até a data de corte e mais outros filtros selecionados", - "cleanup_step4_summary": "{count} arquivos criados antes de {date} foram selecionados para liberar espaço do seu dispositivo", + "cleanup_step3_description": "Procure por arquivos de backup que correspondam à sua data e manter configurações.", + "cleanup_step4_summary": "{count} arquivos criados antes de {date} foram selecionados para liberar espaço do seu dispositivo. Fotos permanecerão acessíveis através do app do Immich.", "cleanup_trash_hint": "Para liberar espaço imediatamente, abra a galeria de fotos original do dispositivo e esvazie a lixeira", "clear": "Limpar", "clear_all": "Limpar tudo", @@ -863,7 +867,7 @@ "custom_locale": "Localização Customizada", "custom_locale_description": "Formatar datas e números baseado no idioma e na região", "custom_url": "URL personalizada", - "cutoff_date_description": "Remover fotos mais antigas que", + "cutoff_date_description": "Manter fotos dos últimos…", "cutoff_day": "{count, plural, one {dia} other {dias}}", "cutoff_year": "{count, plural, one {ano} other {anos}}", "daily_title_text_date": "E, dd MMM", @@ -1015,11 +1019,14 @@ "error_change_sort_album": "Falha ao alterar a ordem de exibição", "error_delete_face": "Erro ao remover face do arquivo", "error_getting_places": "Erro ao buscar os locais", + "error_loading_albums": "Erro ao carregar álbuns", "error_loading_image": "Erro ao carregar a página", "error_loading_partners": "Erro ao carregar parceiros: {error}", + "error_retrieving_asset_information": "Erro ao recuperar informações do arquivo", "error_saving_image": "Erro: {error}", "error_tag_face_bounding_box": "Erro ao marcar o rosto - não foi possível localizar o rosto", "error_title": "Erro - Algo deu errado", + "error_while_navigating": "Erro ao navegar para o arquivo", "errors": { "cannot_navigate_next_asset": "Não foi possível navegar para o próximo arquivo", "cannot_navigate_previous_asset": "Não foi possível navegar para o arquivo anterior", @@ -1188,7 +1195,6 @@ "features": "Funcionalidades", "features_in_development": "Funções em desenvolvimento", "features_setting_description": "Gerenciar as funcionalidades da aplicação", - "file_name": "Arquivo: {file_name}", "file_name_or_extension": "Nome do arquivo ou extensão", "file_size": "Tamanho do arquivo", "filename": "Nome do arquivo", @@ -1208,7 +1214,7 @@ "forgot_pin_code_question": "Esqueceu seu PIN?", "forward": "Para frente", "free_up_space": "Liberar espaço", - "free_up_space_description": "Libere espaço ao mover as fotos e vídeos já com backup no servidor para a lixeira do seu dispositivo. As cópias no servidor ainda existirão e estão a salvo", + "free_up_space_description": "Mova as fotos e vídeos de backup para a lixeira do seu dispositivo para liberar espaço. Suas cópias no servidor permanecem seguras.", "free_up_space_settings_subtitle": "Liberar espaço no dispositivo", "full_path": "Caminho completo: {path}", "gcast_enabled": "Google Cast", @@ -1325,9 +1331,15 @@ "json_editor": "Editor JSON", "json_error": "Erro no JSON", "keep": "Manter", + "keep_albums": "Manter álbuns", + "keep_albums_count": "Mantendo {count} {count, plural, one {álbum} other {álbuns}}", "keep_all": "Manter Todos", + "keep_description": "Escolha o que fica no seu dispositivo ao liberar espaço.", "keep_favorites": "Manter favoritos", + "keep_on_device": "Manter no dispositivo", + "keep_on_device_hint": "Selecione os itens que deseja manter neste dispositivo", "keep_this_delete_others": "Manter este, excluir o resto", + "keeping": "Mantendo: {items}", "kept_this_deleted_others": "Este foi mantido e {count, plural, one {# arquivo foi excluído} other {# arquivos foram excluídos}}", "keyboard_shortcuts": "Atalhos do teclado", "language": "Idioma", @@ -1375,7 +1387,7 @@ "local_network_sheet_info": "O aplicativo irá se conectar ao servidor através deste endereço quando estiver na rede Wi-Fi especificada", "location": "Localização", "location_permission": "Permissão de localização", - "location_permission_content": "Para utilizar a função de troca automática de URL é necessário a permissão de localização precisa, para que seja possível ler o nome da rede Wi-Fi", + "location_permission_content": "Para usar o recurso de alternância automática, o Immich requer permissão de localização precisa para poder ler o nome da rede Wi-Fi atual", "location_picker_choose_on_map": "Escolha no mapa", "location_picker_latitude_error": "Digite uma latitude válida", "location_picker_latitude_hint": "Digite a latitude", @@ -1421,10 +1433,28 @@ "loop_videos_description": "Ative para repetir os vídeos automaticamente durante a exibição.", "main_branch_warning": "Você está utilizando uma versão de desenvolvimento. É fortemente recomendado que utilize uma versão estável!", "main_menu": "Menu Principal", + "maintenance_action_restore": "Restaurando Banco de Dados", "maintenance_description": "O Immich foi colocado em modo de manutenção.", "maintenance_end": "Desativar modo de manutenção", "maintenance_end_error": "Ocorreu um erro ao desativar o modo de manutenção.", "maintenance_logged_in_as": "Usuário atual: {user}", + "maintenance_restore_from_backup": "Restaurar a partir de Backup", + "maintenance_restore_library": "Restaurar Sua Biblioteca", + "maintenance_restore_library_confirm": "Se tudo parecer correto, prossiga com a restauração do backup!", + "maintenance_restore_library_description": "Restaurando o Banco de Dados", + "maintenance_restore_library_folder_has_files": "{folder} possui {count} pasta(s)", + "maintenance_restore_library_folder_no_files": "{folder} está faltando arquivos!", + "maintenance_restore_library_folder_pass": "legível e escrevível", + "maintenance_restore_library_folder_read_fail": "ilegível", + "maintenance_restore_library_folder_write_fail": "não gravável", + "maintenance_restore_library_hint_missing_files": "Talvez estejam faltando arquivos importantes", + "maintenance_restore_library_hint_regenerate_later": "Você pode regenerá-los depois nas configurações", + "maintenance_restore_library_hint_storage_template_missing_files": "Está usando um modelo de armazenamento? Podem estar faltando arquivos", + "maintenance_restore_library_loading": "Carregando verificações de integridade e heurísticas…", + "maintenance_task_backup": "Criando um backup do banco de dados existente…", + "maintenance_task_migrations": "Executando migrações do banco de dados…", + "maintenance_task_restore": "Restaurando o backup escolhido…", + "maintenance_task_rollback": "Falha na restauração, voltando para o ponto de restauração…", "maintenance_title": "Temporariamente Indisponível", "make": "Marca", "manage_geolocation": "Gerenciar localização", @@ -1539,11 +1569,12 @@ "next_memory": "Próxima memória", "no": "Não", "no_actions_added": "Nenhuma ação foi adicionada ainda", + "no_albums_found": "Nenhum álbum encontrado", "no_albums_message": "Crie um álbum para organizar suas fotos e vídeos", "no_albums_with_name_yet": "Parece que você ainda não tem nenhum álbum com esse nome.", "no_albums_yet": "Parece que você ainda não tem nenhum álbum.", "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", - "no_assets_message": "CLIQUE PARA ENVIAR SUA PRIMEIRA FOTO", + "no_assets_message": "Clique aqui para enviar sua primeira foto", "no_assets_to_show": "Não há arquivos para exibir", "no_cast_devices_found": "Nenhum dispositivo encontrado", "no_checksum_local": "Nenhum checksum disponível - não foi possível carregar os arquivos locais", @@ -1568,6 +1599,7 @@ "no_results_description": "Tente um sinônimo ou uma palavra-chave mais geral", "no_shared_albums_message": "Crie um álbum para compartilhar fotos e vídeos com pessoas em sua rede", "no_uploads_in_progress": "Nenhum envio em progresso", + "none": "Nenhum", "not_allowed": "Não permitido", "not_available": "N/A", "not_in_any_album": "Fora de álbum", @@ -1897,6 +1929,7 @@ "search_filter_media_type_title": "Selecione o tipo de mídia", "search_filter_ocr": "Buscar por OCR", "search_filter_people_title": "Selecione pessoas", + "search_filter_star_rating": "Avaliação", "search_for": "Pesquisar por", "search_for_existing_person": "Pesquisar por pessoas", "search_no_more_result": "Não há mais resultados", @@ -1957,7 +1990,7 @@ "selected_gps_coordinates": "Coordenadas de GPS Selecionada", "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", - "server_endpoint": "URL do servidor", + "server_endpoint": "URL do Servidor", "server_info_box_app_version": "Versão do aplicativo", "server_info_box_server_url": "Endereço", "server_offline": "Servidor Indisponível", @@ -2101,6 +2134,8 @@ "skip_to_folders": "Ir para pastas", "skip_to_tags": "Ir para os marcadores", "slideshow": "Apresentação", + "slideshow_repeat": "Repetir apresentação de slides", + "slideshow_repeat_description": "Voltar para o início quando a apresentação terminar", "slideshow_settings": "Opções de apresentação", "sort_albums_by": "Ordenar álbuns por...", "sort_created": "Data de criação", @@ -2177,6 +2212,7 @@ "theme_setting_theme_subtitle": "Escolha a configuração de tema do app", "theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios oferece a imagem de melhor qualidade em troca de uma velocidade de carregamento mais lenta", "theme_setting_three_stage_loading_title": "Ative o carregamento em três estágios", + "then": "Antes", "they_will_be_merged_together": "Eles serão mesclados", "third_party_resources": "Recursos de terceiros", "time": "Hora", @@ -2232,6 +2268,7 @@ "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", "unknown_country": "País desconhecido", + "unknown_date": "Data desconhecida", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", "unlink_motion_video": "Remover relação com video animado", @@ -2260,6 +2297,7 @@ "upload_details": "Detalhes do envio", "upload_dialog_info": "Deseja fazer o backup dos arquivos selecionados no servidor?", "upload_dialog_title": "Enviar arquivo", + "upload_error_with_count": "Erro de envio para {count, plural, one {# arquivo} other {# arquivos}}", "upload_errors": "Envio concluído com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos arquivos.", "upload_finished": "Envio finalizado", "upload_progress": "{remaining, number} restantes - {processed, number}/{total, number} já processados", diff --git a/i18n/ro.json b/i18n/ro.json index c1b4046c67..b9b04b7cce 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -104,6 +104,8 @@ "image_preview_description": "Imagine de dimensiune medie cu metadate eliminate, utilizată la vizualizarea unui singur element și pentru învățarea automată", "image_preview_quality_description": "Calitatea previzualizării de la 1 la 100. O valoare mai mare oferă o calitate mai bună, dar produce fișiere mai mari și poate reduce receptivitatea aplicației. Setarea unei valori scăzute poate afecta calitatea învățării automate.", "image_preview_title": "Previzualizați setările", + "image_progressive": "Progresiv", + "image_progressive_description": "Encodează imaginile JPEG progresiv, pentru încărcare graduală.Fără efect pentru imaginile WebP", "image_quality": "Calitate", "image_resolution": "Rezolutie", "image_resolution_description": "Rezoluțiile mai mari pot păstra mai multe detalii, dar necesită mai mult timp pentru a fi codificate, au dimensiuni mai mari ale fișierelor și pot reduce răspunsul aplicației.", @@ -192,12 +194,17 @@ "maintenance_delete_backup_description": "Acest fisier va fi sters permanent.", "maintenance_delete_error": "Stergerea backup-ului nu a reusit.", "maintenance_restore_backup": "Restaureaza Backup", + "maintenance_restore_backup_description": "Immich va fi șters si restaurat din backup-ul ales. Va fi creat un nou backup înainte de a continua.", "maintenance_restore_backup_different_version": "Acest backup a fost creat folosind o versiune diferita de Immich!", "maintenance_restore_backup_unknown_version": "Versiunea de backup nu a putut fi determinată.", + "maintenance_restore_database_backup": "Restaurează baza de date din backup", + "maintenance_restore_database_backup_description": "Restaureaza la o bază de date precedentă folosind un fisier backup", "maintenance_settings": "Întreținere", "maintenance_settings_description": "Puneți Immich în modul de întreținere.", - "maintenance_start": "Pornește modul de întreținere", + "maintenance_start": "Schimbă la modul de întreținere", "maintenance_start_error": "Nu s-a putut porni modul de întreținere.", + "maintenance_upload_backup": "Încarcă fișier backup pentru baza de date", + "maintenance_upload_backup_error": "Nu s-a putut încărca backupul, e un fișier .sql/.sql.gz?", "manage_concurrency": "Gestionează sarcinile paralele", "manage_concurrency_description": "Accesează pagina de joburi pentru a gestiona concurența lor", "manage_log_settings": "Administrați setările jurnalului", @@ -444,6 +451,9 @@ "admin_password": "Parolă administrator", "administration": "Administrare", "advanced": "Avansat", + "advanced_settings_clear_image_cache": "Șterge cache-ul", + "advanced_settings_clear_image_cache_error": "Ștergerea cache-ului de imagini a eșuat", + "advanced_settings_clear_image_cache_success": "{size} șterși cu succes", "advanced_settings_enable_alternate_media_filter_subtitle": "Utilizați această opțiune pentru a filtra conținutul media în timpul sincronizării pe baza unor criterii alternative. Încercați numai dacă întâmpinați probleme cu aplicația la detectarea tuturor albumelor.", "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Utilizați filtrul alternativ de sincronizare a albumelor de pe dispozitiv", "advanced_settings_log_level_title": "Nivel log: {level}", @@ -507,6 +517,7 @@ "all": "Toate", "all_albums": "Toate albumele", "all_people": "Toți oamenii", + "all_photos": "Toate fotografiile", "all_videos": "Toate videoclipurile", "allow_dark_mode": "Permite mod întunecat", "allow_edits": "Permite editări", @@ -514,6 +525,9 @@ "allow_public_user_to_upload": "Permite utilizatorului public să încarce", "allowed": "Permis", "alt_text_qr_code": "Cod QR", + "always_keep": "Păstrează întotdeauna", + "always_keep_photos_hint": "Eliberează Spațiu va păstra toate fotografiile de pe acest dispozitiv.", + "always_keep_videos_hint": "Eliberează Spațiu va păstra toate video-urile de pe acest dispozitiv.", "anti_clockwise": "În sens invers acelor de ceasornic", "api_key": "Cheie API", "api_key_description": "Această valoare va fi afișată o singură dată. Vă rugăm să vă asigurați că o copiați înainte de a închide fereastra.", @@ -558,6 +572,9 @@ "asset_list_layout_sub_title": "Aspect", "asset_list_settings_subtitle": "Setări format grilă fotografii", "asset_list_settings_title": "Grilă fotografii", + "asset_not_found_on_device_android": "Obiect negăsit pe dispozitiv", + "asset_not_found_on_device_ios": "Obiect negăsit pe dispozitiv.Dacă folosești iCloud, obiectul poate fi inaccesibil din cauza stocării incorecte pe iCloud", + "asset_not_found_on_icloud": "Obiect negăsit pe iCloud. Obiectul poate fi inaccesibil din cauza stocării incorecte pe iCloud", "asset_offline": "Resursă Offline", "asset_offline_description": "Această resursă externă nu mai este găsită pe disc. Contactează te rog administratorul tău Immich pentru ajutor.", "asset_restored_successfully": "Date restaurate cu succes", @@ -747,6 +764,7 @@ "cleanup_deleted_assets": "Muta {count} materiale in coșul de gunoi", "cleanup_deleting": "Se șterge...", "cleanup_found_assets": "Am găsit {count} materiale in copia de rezerva", + "cleanup_found_assets_with_size": "{count} obiecte găsite ({size})", "cleanup_icloud_shared_albums_excluded": "Albumele partajate iCLoud sunt excluse de la cautare", "cleanup_no_assets_found": "Nici un material in copia de rezerva găsit după criteriu", "cleanup_preview_title": "Materiale sa fie șterse ({count})", @@ -851,6 +869,7 @@ "custom_url": "URL personalizat", "cutoff_date_description": "Eliminați fotografiile și videoclipurile mai vechi de", "cutoff_day": "{count, plural, o {day} mai multe {days}}", + "cutoff_year": "{count, plural, =0 {0 ani} one {# an} few {# ani} other {# de ani}}", "daily_title_text_date": "E, LLL zz", "daily_title_text_date_year": "E, LLL zz, aaaa", "dark": "Întunecat", @@ -1000,11 +1019,14 @@ "error_change_sort_album": "Nu s-a putut modifica ordinea de sortare a albumului", "error_delete_face": "Eroare la ștergerea feței din activ", "error_getting_places": "Eroare la obținerea locațiilor", + "error_loading_albums": "Eroare la încărcarea albumelor", "error_loading_image": "Eroare la încărcarea imaginii", "error_loading_partners": "Eroare la încărcarea partenerilor: {error}", + "error_retrieving_asset_information": "Eroare la colectarea informațiilor obiectului", "error_saving_image": "Eroare: {error}", "error_tag_face_bounding_box": "Eroare la etichetarea feței - nu se pot obține coordonatele casetei de delimitare", "error_title": "Eroare - ceva nu a mers", + "error_while_navigating": "Eroare la navigarea spre obiect", "errors": { "cannot_navigate_next_asset": "Nu se poate naviga către următoarea resursă", "cannot_navigate_previous_asset": "Nu se poate naviga la resursa anterioară", @@ -1173,7 +1195,6 @@ "features": "Caracteristici", "features_in_development": "Funcții în dezvoltare", "features_setting_description": "Gestionați funcțiile aplicației", - "file_name": "Nume de fișier: {file_name}", "file_name_or_extension": "Numele sau extensia fișierului", "file_size": "Mărime fișier", "filename": "Numele fișierului", @@ -1310,9 +1331,15 @@ "json_editor": "Editor JSON", "json_error": "Eroare JSON", "keep": "Păstrați", + "keep_albums": "Păstreaza albume", + "keep_albums_count": "Păstrez {count} {count, plural, one {album} few {albume} other {de albume}}", "keep_all": "Păstrați Tot", + "keep_description": "Alege ce să rămână pe dispozitiv când eliberezi spațiu.", "keep_favorites": "Păstrați favoritele", + "keep_on_device": "Păstrează pe dispozitiv", + "keep_on_device_hint": "Selectează ce să rămână pe dispozitiv", "keep_this_delete_others": "Păstrați asta, ștergeți celelalte", + "keeping": "Păstrez: {items}", "kept_this_deleted_others": "S-a păstrat acest material și s-au șters {count, plural, one {# material} other {# materiale}}", "keyboard_shortcuts": "Comenzi rapide de tastatură", "language": "Limbă", @@ -1406,10 +1433,28 @@ "loop_videos_description": "Activați pentru a rula in buclă automat un videoclip în vizualizatorul de detalii.", "main_branch_warning": "Utilizați o versiune de dezvoltare; vă recomandăm insistent să utilizați o versiune de lansare!", "main_menu": "Meniu principal", + "maintenance_action_restore": "Restaurare bază de date", "maintenance_description": "Immich a fost pus în modul de întreținere.", "maintenance_end": "Ieșire din modul de întreținere", "maintenance_end_error": "Nu s-a reușit ieșirea din modul de întreținere.", "maintenance_logged_in_as": "Conectat în prezent ca {user}", + "maintenance_restore_from_backup": "Restaurează din backup", + "maintenance_restore_library": "Restaurează-ți biblioteca", + "maintenance_restore_library_confirm": "Dacă pare corect, continuă spre a restaura un backup!", + "maintenance_restore_library_description": "Restaurare bază de date", + "maintenance_restore_library_folder_has_files": "{folder} are {count} {count, plural, one {fișier} few {fișiere} other {de fișiere}}", + "maintenance_restore_library_folder_no_files": "Lipsesc fișiere din {folder}!", + "maintenance_restore_library_folder_pass": "permite scrierea și citirea", + "maintenance_restore_library_folder_read_fail": "nu permite citirea", + "maintenance_restore_library_folder_write_fail": "nu permite scrierea", + "maintenance_restore_library_hint_missing_files": "Posibil să lipsească fișiere importante", + "maintenance_restore_library_hint_regenerate_later": "Poți regenera mai tarziu în setări", + "maintenance_restore_library_hint_storage_template_missing_files": "Folosesti șablonul de stocare? Posibil să-ți lipsească fișiere", + "maintenance_restore_library_loading": "Încarc verificările de integritate si euristice…", + "maintenance_task_backup": "Creez backupul bazei de date existente…", + "maintenance_task_migrations": "Rulez migrările bazei de date…", + "maintenance_task_restore": "Restaurez backupul ales…", + "maintenance_task_rollback": "Restaurarea a eșuat, întorc la punctul de restaurare…", "maintenance_title": "Temporar indisponibil", "make": "Marcă", "manage_geolocation": "Gestionați locația", @@ -1524,6 +1569,7 @@ "next_memory": "Următoarea amintire", "no": "Nu", "no_actions_added": "Nu s-au adăugat încă acțiuni", + "no_albums_found": "Niciun album găsit", "no_albums_message": "Creați un album pentru a vă organiza fotografiile și videoclipurile", "no_albums_with_name_yet": "Se pare că nu aveți încă niciun album cu acest nume.", "no_albums_yet": "Se pare că nu aveți încă niciun album.", @@ -1553,6 +1599,7 @@ "no_results_description": "Încercați un sinonim sau un cuvânt cheie mai general", "no_shared_albums_message": "Creați un album pentru a partaja fotografii și videoclipuri cu persoanele din rețeaua dvs", "no_uploads_in_progress": "Nicio încărcare în curs", + "none": "Niciunul", "not_allowed": "Nu este permis", "not_available": "N/A", "not_in_any_album": "Nu există în niciun album", @@ -1635,6 +1682,7 @@ "people": "Persoane", "people_edits_count": "Editat {count, plural, one {# persoană} other {# persoane}}", "people_feature_description": "Răsfoiți fotografii și videoclipuri grupate după persoane", + "people_selected": "{count, plural,one {# persoană selectată} few {# persoane selectate}other {# de persoane selectate}}", "people_sidebar_description": "Afișează un link către persoane în bara laterală", "permanent_deletion_warning": "Avertisment de ștergere permanentă", "permanent_deletion_warning_setting_description": "Afișează un avertisment la ștergerea definitivă a resurselor", @@ -1742,6 +1790,7 @@ "purchase_settings_server_activated": "Cheia de produs a serverului este gestionată de administrator", "query_asset_id": "Interoghează ID-ul resursei", "queue_status": "Se pun în coadă {count}/{total}", + "rate_asset": "Dă o notă", "rating": "Evaluare cu stele", "rating_clear": "Anuleaza evaluarea", "rating_count": "{count, plural, one {# stea} other {# stele}}", @@ -1880,6 +1929,7 @@ "search_filter_media_type_title": "Selectați tipul media", "search_filter_ocr": "Caută dupa OCR", "search_filter_people_title": "Selectați persoane", + "search_filter_star_rating": "După rating în stele", "search_for": "Căutare după", "search_for_existing_person": "Caută o persoană existentă", "search_no_more_result": "Nu mai există rezultate", @@ -1914,17 +1964,23 @@ "second": "Secundǎ", "see_all_people": "Vizualizează toate persoanele", "select": "Selectează", + "select_album": "Selectează album", "select_album_cover": "Selectați coperta albumului", + "select_albums": "Selectează albume", "select_all": "Selectați tot", "select_all_duplicates": "Selectați toate duplicatele", "select_all_in": "Selectați tot în {group}", "select_avatar_color": "Selectați culoarea avatarului", + "select_count": "{count, plural, one {Selectează #} few {Selectează #} other {Selectează #}}", + "select_cutoff_date": "Selectează data limită", "select_face": "Selectați fața", "select_featured_photo": "Selectați fotografia recomandată", "select_from_computer": "Selectați din calculator", "select_keep_all": "Selectați tot pentru păstrare", "select_library_owner": "Selectați proprietarul bibliotecii", "select_new_face": "Selectați o nouǎ fațǎ", + "select_people": "Selectează oameni", + "select_person": "Selectează persoana", "select_person_to_tag": "Selectați o persoană pentru a o eticheta", "select_photos": "Selectați fotografii", "select_trash_all": "Selectați tot pentru ștergere", @@ -2060,6 +2116,7 @@ "show_password": "Afișați parola", "show_person_options": "Afișați opțiunile persoanelor", "show_progress_bar": "Afișați Bara de Progres", + "show_schema": "Arată schema", "show_search_options": "Afișați opțiunile de căutare", "show_shared_links": "Afișare linkuri partajate", "show_slideshow_transition": "Afișați tranziția de prezentare", @@ -2077,6 +2134,8 @@ "skip_to_folders": "Treceți la foldere", "skip_to_tags": "Treceți la etichete", "slideshow": "Prezentare de diapozitive", + "slideshow_repeat": "Repetă prezentarea", + "slideshow_repeat_description": "Reîntoarce-te la început cand prezentarea se încheie", "slideshow_settings": "Setări pentru prezentarea de diapozitive", "sort_albums_by": "Sortați albumele după...", "sort_created": "Data creării", @@ -2153,6 +2212,7 @@ "theme_setting_theme_subtitle": "Alege tema aplicației", "theme_setting_three_stage_loading_subtitle": "Încărcarea în trei etape are putea crește performanța încărcării dar generează un volum semnificativ mai mare de trafic pe rețea", "theme_setting_three_stage_loading_title": "Pornește încărcarea în 3 etape", + "then": "Atunci", "they_will_be_merged_together": "Vor fi îmbinate împreună", "third_party_resources": "Resurse Terță Parte", "time": "Timp", @@ -2187,6 +2247,13 @@ "trash_page_select_assets_btn": "Selectează resurse", "trash_page_title": "Coș ({count})", "trashed_items_will_be_permanently_deleted_after": "Elementele din coșul de gunoi vor fi șterse definitiv după {days, plural, one {# zi} other {# zile}}.", + "trigger": "Declanșator", + "trigger_asset_uploaded": "Fișier încărcat", + "trigger_asset_uploaded_description": "Declanșează cand un fișier este încarcat", + "trigger_description": "Un eveniment care declanșează fluxul de lucru", + "trigger_person_recognized": "Persoana Recunoscută", + "trigger_person_recognized_description": "Declanșat atunci când este detectată o persoană", + "trigger_type": "Tip de declanșare", "troubleshoot": "Depanați", "type": "Tip", "unable_to_change_pin_code": "Nu se poate schimba codul PIN", @@ -2201,6 +2268,7 @@ "unhide_person": "Dezvăluie persoana", "unknown": "Necunoscut", "unknown_country": "Țară necunoscută", + "unknown_date": "Dată necunoscută", "unknown_year": "An Necunoscut", "unlimited": "Nelimitat", "unlink_motion_video": "Deconectați videoclipul în mișcare", @@ -2217,7 +2285,9 @@ "unstack": "Dezasamblați", "unstack_action_prompt": "{count} neîmpachetate", "unstacked_assets_count": "Nestivuit {count, plural, one {# resursă} other {# resurse}}", + "unsupported_field_type": "Tip de câmp neacceptat", "untagged": "Neetichetat", + "untitled_workflow": "Flux fara titlu", "up_next": "Mai departe", "update_location_action_prompt": "Actualizează locația pentru {count} resurse selectate cu:", "updated_at": "Actualizat", @@ -2227,6 +2297,7 @@ "upload_details": "Detalii încărcare", "upload_dialog_info": "Vrei să backup resursele selectate pe server?", "upload_dialog_title": "Încarcă resursă", + "upload_error_with_count": "Eroare la încărcare pentru {count, plural, one {# fișier} other {# fișiere}}", "upload_errors": "Încărcare finalizată cu {count, plural, one {# eroare} other {# erori}}, reîmprospătați pagina pentru a reîncărca noile resurse.", "upload_finished": "Încărcarea s-a finalizat", "upload_progress": "Rămas {remaining, number} - Procesat {processed, number}/{total, number}", @@ -2262,6 +2333,7 @@ "utilities": "Utilitǎți", "validate": "Validați", "validate_endpoint_error": "Vă rugăm să introduceți o adresă URL validă", + "validation_error": "Eroare de validare", "variables": "Variabile", "version": "Versiune", "version_announcement_closing": "Prietenul tǎu, Alex", @@ -2273,10 +2345,12 @@ "video_hover_setting_description": "Redați miniatura video când mouse-ul trece peste element. Chiar și atunci când este dezactivată, redarea poate fi pornită trecând cu mouse-ul peste pictograma de redare.", "videos": "Videoclipuri", "videos_count": "{count, plural, one {# Videoclip} other {# Videoclipuri}}", + "videos_only": "Doar videoclipuri", "view": "Secțiune", "view_album": "Vizualizează Album", "view_all": "Vizualizează Tot", "view_all_users": "Vizulizați toți utilizatorii", + "view_asset_owners": "Vezi proprietarii resursei", "view_details": "Vedeți detaliile", "view_in_timeline": "Vizualizează în cronologie", "view_link": "Vezi link", @@ -2292,18 +2366,36 @@ "viewer_stack_use_as_main_asset": "Folosește ca resursă principală", "viewer_unstack": "Anulează grup", "visibility_changed": "Vizibilitatea schimbată pentru {count, plural, one {# persoană} other {# persoane}}", + "visual": "Vizual", + "visual_builder": "Constructor vizual", "waiting": "În așteptare", + "waiting_count": "În așteptare: {count}", "warning": "Avertisment", "week": "Sǎptǎmânǎ", "welcome": "Bun venit", "welcome_to_immich": "Bun venit la Immich", + "width": "Lățime", "wifi_name": "Nume Wi-Fi", + "workflow_delete_prompt": "Ești sigur că vrei să ștergi acest flux de lucru?", + "workflow_deleted": "Flux de lucru șters", + "workflow_description": "Descrierea fluxului de lucru", + "workflow_info": "Informații despre fluxul de lucru", + "workflow_json": "Flux de lucru JSON", + "workflow_json_help": "Editează configurația fluxului de lucru în format JSON. Modificările vor fi sincronizate cu constructorul vizual.", + "workflow_name": "Numele fluxului de lucru", + "workflow_navigation_prompt": "Ești sigur că vrei să părăsești fără să salvezi modificările?", + "workflow_summary": "Rezumatul fluxului de lucru", + "workflow_update_success": "Fluxul de lucru a fost actualizat cu succes", + "workflow_updated": "Fluxul de lucru a fost actualizat", + "workflows": "Fluxuri de lucru", + "workflows_help_text": "Fluxurile de lucru automatizează acțiuni pe resurse, folosind declanșatori și filtre", "wrong_pin_code": "Cod PIN greșit", "year": "An", "years_ago": "acum {years, plural, one {# an} other {# ani}} în urmă", "yes": "Da", "you_dont_have_any_shared_links": "Nu aveți linkuri partajate", "your_wifi_name": "Numele rețelei tale WiFi", + "zero_to_clear_rating": "apasă 0 pentru a reseta evaluarea resursei", "zoom_image": "Măriți Imaginea", "zoom_to_bounds": "Mărește la margini" } diff --git a/i18n/ru.json b/i18n/ru.json index 01b5b3a2f4..ed94f6de71 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -1195,7 +1195,6 @@ "features": "Дополнительные возможности", "features_in_development": "Функции в разработке", "features_setting_description": "Управление дополнительными возможностями приложения", - "file_name": "Имя файла: {file_name}", "file_name_or_extension": "Имя файла или расширение", "file_size": "Размер файла", "filename": "Имя файла", diff --git a/i18n/sk.json b/i18n/sk.json index f628995254..197b9717c5 100644 --- a/i18n/sk.json +++ b/i18n/sk.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Rozvrhnutie", "asset_list_settings_subtitle": "Nastavenia rozloženia mriežky fotografií", "asset_list_settings_title": "Mriežka fotografií", + "asset_not_found_on_device_android": "Položka nebola nájdená v zariadení", + "asset_not_found_on_device_ios": "Položka nebola nájdená v zariadení. Ak používate iCloud, položka môže byť nedostupná kvôli poškodenému súboru uloženému v iCloude", + "asset_not_found_on_icloud": "Položka nebola nájdená v iCloude. Položka môže byť nedostupná kvôli poškodenému súboru uloženému v iCloude", "asset_offline": "Médium je offline", "asset_offline_description": "Tento externá položka sa už nenachádza na disku. Pre pomoc sa prosím obráťte na správcu systému Immich.", "asset_restored_successfully": "Položky boli úspešne obnovené", @@ -779,6 +782,8 @@ "client_cert_import": "Importovať", "client_cert_import_success_msg": "Certifikát klienta je naimportovaný", "client_cert_invalid_msg": "Neplatný súbor certifikátu alebo nesprávne heslo", + "client_cert_password_message": "Zadajte heslo pre tento certifikát", + "client_cert_password_title": "Heslo certifikátu", "client_cert_remove_msg": "Certifikát klienta je odstránený", "client_cert_subtitle": "Podporuje iba formát PKCS12 (.p12, .pfx). Importovanie/odstránenie certifikátu je k dispozícii len pred prihlásením", "client_cert_title": "SSL certifikát klienta [EXPERIMENTÁLNE]", @@ -864,7 +869,7 @@ "custom_locale": "Vlastné nastavenie jazyka", "custom_locale_description": "Formátovanie dátumov a čísel podľa jazyka a regiónu", "custom_url": "Vlastná URL adresa", - "cutoff_date_description": "Ponechať fotografie z posledného…", + "cutoff_date_description": "Ponechať fotografie z posledného obdobia…", "cutoff_day": "{count, plural, one {deň} few {dni} other {dní}}", "cutoff_year": "{count, plural, one {rok} few {roky} other {rokov}}", "daily_title_text_date": "EEEE, d. MMMM", @@ -1192,8 +1197,9 @@ "features": "Funkcie", "features_in_development": "Funkcie vo vývoji", "features_setting_description": "Spravovať funkcie aplikácie", - "file_name": "Názov súboru: {file_name}", "file_name_or_extension": "Názov alebo prípona súboru", + "file_name_text": "Názov súboru", + "file_name_with_value": "Názov súboru: {file_name}", "file_size": "Veľkosť súboru", "filename": "Názov súboru", "filetype": "Typ súboru", @@ -1970,7 +1976,7 @@ "select_all_in": "Označiť všetky v {group}", "select_avatar_color": "Vyberte farbu avatara", "select_count": "{count, plural, one {Vybrať #} other {Vybrať #}}", - "select_cutoff_date": "Vybrať dátum konca", + "select_cutoff_date": "Vybrať cieľový dátum", "select_face": "Vyberte tvár", "select_featured_photo": "Vyberte náhľadovú fotku", "select_from_computer": "Vybrať z počítača", @@ -2295,6 +2301,7 @@ "upload_details": "Podrobnosti o nahrávaní", "upload_dialog_info": "Chcete zálohovať zvolené médiá na server?", "upload_dialog_title": "Nahrať médiá", + "upload_error_with_count": "Chyba pri nahrávaní {count, plural, one {# položky} few {# položiek} other {# položiek}}", "upload_errors": "Nahrávanie ukončené s {count, plural, one {# chybou} other {# chybami}}, obnovte stránku, aby sa zobrazili nové položky.", "upload_finished": "Nahrávanie dokončené", "upload_progress": "Ostáva {remaining, number} - Spracovaných {processed, number}/{total, number}", diff --git a/i18n/sl.json b/i18n/sl.json index c9c52d3dcd..76e1783f71 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Postavitev", "asset_list_settings_subtitle": "Nastavitve postavitve mreže fotografij", "asset_list_settings_title": "Mreža fotografij", + "asset_not_found_on_device_android": "Sredstva ni bilo mogoče najti v napravi", + "asset_not_found_on_device_ios": "Sredstva ni bilo mogoče najti v napravi. Če uporabljate iCloud, sredstvo morda ni dostopno zaradi napačne datoteke, shranjene v iCloudu", + "asset_not_found_on_icloud": "Sredstva ni bilo mogoče najti v iCloudu. Sredstvo morda ni dostopno zaradi napačne datoteke, shranjene v iCloudu", "asset_offline": "Sredstvo brez povezave", "asset_offline_description": "Tega zunanjega sredstva ni več mogoče najti na disku. Za pomoč kontaktirajte Immich skrbnika.", "asset_restored_successfully": "Sredstvo uspešno obnovljeno", @@ -1192,7 +1195,6 @@ "features": "Funkcije", "features_in_development": "Funkcije v razvoju", "features_setting_description": "Upravljaj funkcije aplikacije", - "file_name": "Ime datoteke: {file_name}", "file_name_or_extension": "Ime ali končnica datoteke", "file_size": "Velikost datoteke", "filename": "Ime datoteke", @@ -1228,7 +1230,7 @@ "go_to_search": "Pojdi na iskanje", "gps": "GPS", "gps_missing": "Brez GPS-a", - "grant_permission": "Podeli dovoljenje", + "grant_permission": "Dodaj dovoljenje", "group_albums_by": "Združi albume po ...", "group_country": "Združi po državah", "group_no": "Brez združevanja", @@ -1606,7 +1608,7 @@ "notes": "Opombe", "nothing_here_yet": "Tukaj še ni ničesar", "notification_permission_dialog_content": "Če želite omogočiti obvestila, pojdite v Nastavitve in izberite Dovoli.", - "notification_permission_list_tile_content": "Izdaj dovoljenje za omogočanje obvestil.", + "notification_permission_list_tile_content": "Dodaj dovoljenje za pošiljanje obvestil.", "notification_permission_list_tile_enable_button": "Omogoči obvestila", "notification_permission_list_tile_title": "Dovoljenje za obvestila", "notification_toggle_setting_description": "Omogoči e-poštna obvestila", @@ -1695,8 +1697,8 @@ "permission_onboarding_continue_anyway": "Vseeno nadaljuj", "permission_onboarding_get_started": "Začnimo", "permission_onboarding_go_to_settings": "Pojdite na nastavitve", - "permission_onboarding_permission_denied": "Dovoljenje zavrnjeno. Če želite uporabljati Immich, v nastavitvah podelite dovoljenja za fotografije in videoposnetke.", - "permission_onboarding_permission_granted": "Dovoljenje je izdano! Vse je pripravljeno.", + "permission_onboarding_permission_denied": "Dovoljenje zavrnjeno. Če želite uporabljati Immich, v nastavitvah dodajte dovoljenja za fotografije in videoposnetke.", + "permission_onboarding_permission_granted": "Dovoljenje ste dodali! Vse je pripravljeno.", "permission_onboarding_permission_limited": "Dovoljenje je omejeno. Če želite Immichu dovoliti varnostno kopiranje in upravljanje vaše celotne zbirke galerij, v nastavitvah podelite dovoljenja za fotografije in videoposnetke.", "permission_onboarding_request": "Immich potrebuje dovoljenje za ogled vaših fotografij in videoposnetkov.", "person": "Oseba", @@ -2295,6 +2297,7 @@ "upload_details": "Podrobnosti o nalaganju", "upload_dialog_info": "Ali želite varnostno kopirati izbrana sredstva na strežnik?", "upload_dialog_title": "Naloži sredstvo", + "upload_error_with_count": "Napaka pri prilaganju za {count, plural, one {# sredstvo} two {# sredstvi} few {# sredstva} other {# sredstev}}", "upload_errors": "Nalaganje je končano s/z {count, plural, one {# napako} two {# napakama} other {# napakami}}, osvežite stran, da vidite nova sredstva za nalaganje.", "upload_finished": "Nalaganje končano", "upload_progress": "Preostalo {remaining, number} - Obdelano {processed, number}/{total, number}", diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index 3888896ffb..d656ac248e 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -987,7 +987,6 @@ "feature_photo_updated": "Главна фотографија је ажурирана", "features": "Функције (феатурес)", "features_setting_description": "Управљајте функцијама апликације", - "file_name": "Назив документа", "file_name_or_extension": "Име датотеке или екстензија", "filename": "Име датотеке", "filetype": "Врста документа", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index 5b94e1361d..b6f36d8c70 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -971,7 +971,6 @@ "feature_photo_updated": "Glavna fotografija je ažurirana", "features": "Funkcije (features)", "features_setting_description": "Upravljajte funkcijama aplikacije", - "file_name": "Naziv dokumenta", "file_name_or_extension": "Ime datoteke ili ekstenzija", "filename": "Ime datoteke", "filetype": "Vrsta dokumenta", diff --git a/i18n/sv.json b/i18n/sv.json index eb164be614..280af17550 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Layoutinställningar för bildrutnät", "asset_list_settings_title": "Bildrutnät", + "asset_not_found_on_device_android": "Tillgångar hittades inte på enheten", + "asset_not_found_on_device_ios": "Tillgångar hittades inte på enheten. Om du använder iCloud kan tillgången vara oåtkomlig på grund av en felaktig fil som lagrats på iCloud", + "asset_not_found_on_icloud": "Tillgångar hittades inte på iCloud. Tillgången kan vara oåtkomlig på grund av en felaktig fil som lagras på iCloud", "asset_offline": "Tillgång offline", "asset_offline_description": "Denna externa tillgång finns inte längre på disken. Kontakta din Immich-administratör för hjälp.", "asset_restored_successfully": "Objekt återställt", @@ -763,7 +766,7 @@ "cleanup_found_assets": "Hittade {count} säkerhetskopierade material", "cleanup_found_assets_with_size": "Hittade {count} säkerhetskopierade tillgångar ({size})", "cleanup_icloud_shared_albums_excluded": "iCloud delade album exkluderas från skanningen", - "cleanup_no_assets_found": "Inga tillgångar hittades som matchar kriterierna ovan. Frigör utrymme kan bara ta bort tillgångar som har säkerhetskopierats till servern.", + "cleanup_no_assets_found": "Inga tillgångar hittades som matchar kriterierna ovan. Frigör utrymme kan bara ta bort tillgångar som har säkerhetskopierats till servern", "cleanup_preview_title": "Material att ta bort {count}", "cleanup_step3_description": "Skanna efter säkerhetskopierade tillgångar som matchar ditt datum och behåll inställningarna.", "cleanup_step4_summary": "{count} tillgångar (skapade före {date}) att tas bort från din lokala enhet. Foton kommer att förbli tillgängliga från Immich-appen.", @@ -1192,7 +1195,6 @@ "features": "Funktioner", "features_in_development": "Funktioner i utveckling", "features_setting_description": "Hantera appens funktioner", - "file_name": "Filnamn: {file_name}", "file_name_or_extension": "Filnamn eller -tillägg", "file_size": "Filstorlek", "filename": "Filnamn", @@ -2295,6 +2297,7 @@ "upload_details": "Uppladdningsdetaljer", "upload_dialog_info": "Vill du säkerhetskopiera de valda objekten till servern?", "upload_dialog_title": "Ladda Upp Objekt", + "upload_error_with_count": "Uppladdningsfel för {count, plural, one {# asset} other {# assets}}", "upload_errors": "Uppladdning klar med {count, plural, one {# fel} other {# fel}}, ladda om sidan för att se nya objekt.", "upload_finished": "Uppladdningen är klar", "upload_progress": "Återstående {remaining, number} - Bearbetade {processed, number}/{total, number}", diff --git a/i18n/ta.json b/i18n/ta.json index 1c8bb42b9f..e27bdfd0cb 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -77,7 +77,7 @@ "copy_config_to_clipboard_description": "தற்போதைய கணினி உள்ளமைவை JSON பொருளாக கிளிப்போர்டுக்கு நகலெடுக்கவும்", "create_job": "வேலையை உருவாக்கு", "cron_expression": "க்ரோன் வெளிப்பாடு", - "cron_expression_description": "CRON வடிவமைப்பைப் பயன்படுத்தி ச்கேனிங் இடைவெளியை அமைக்கவும். மேலும் தகவலுக்கு எ.கா. க்ரோன்டாப் குரு ", + "cron_expression_description": "க்ரோன் வடிவமைப்பைப் பயன்படுத்தி ச்கேனிங் இடைவெளியை அமைக்கவும். மேலும் தகவலுக்கு எ.கா. க்ரோன்டாப் குரு ", "cron_expression_presets": "க்ரோன் வெளிப்பாடு முன்னமைவுகள்", "disable_login": "உள்நுழைவை முடக்கு", "duplicate_detection_job_description": "ஒத்த படங்களைக் கண்டறிய, சொத்துக்களில் இயந்திரக் கற்றலை இயக்கவும். ஸ்மார்ட் தேடலை நம்பியுள்ளது", @@ -1118,7 +1118,6 @@ "features": "நற்பொருத்தங்கள்", "features_in_development": "வளர்ச்சியில் நற்பொருத்தங்கள்", "features_setting_description": "பயன்பாட்டு அம்சங்களை நிர்வகிக்கவும்", - "file_name": "கோப்பு பெயர்", "file_name_or_extension": "கோப்பு பெயர் அல்லது நீட்டிப்பு", "file_size": "கோப்பு அளவு", "filename": "கோப்புப்பெயர்", diff --git a/i18n/te.json b/i18n/te.json index 97c495987d..d9d24bb3c6 100644 --- a/i18n/te.json +++ b/i18n/te.json @@ -731,7 +731,6 @@ "feature_photo_updated": "ఫీచర్ ఫోటో నవీకరించబడింది", "features": "లక్షణాలు", "features_setting_description": "యాప్ ఫీచర్‌లను నిర్వహించండి", - "file_name": "ఫైల్ పేరు", "file_name_or_extension": "ఫైల్ పేరు లేదా పొడిగింపు", "filename": "ఫైలుపేరు", "filetype": "ఫైల్ రకం", diff --git a/i18n/th.json b/i18n/th.json index b887c68b7f..abe9b93f19 100644 --- a/i18n/th.json +++ b/i18n/th.json @@ -445,6 +445,8 @@ "allow_public_user_to_download": "อนุญาตให้ผู้ใช้สาธารณะดาวน์โหลดได้", "allow_public_user_to_upload": "อนุญาตให้ผู้ใช้สาธารณะอัปโหลดได้", "alt_text_qr_code": "รูปภาพ QR code", + "always_keep_photos_hint": "\"เพิ่มพื้นที่ว่าง\" จะเก็บรูปภาพทั้งหมดบนอุปกรณ์นี้", + "always_keep_videos_hint": "\"เพิ่มพื้นที่ว่าง\" จะเก็บวิดีโอทั้งหมดบนอุปกรณ์นี้", "anti_clockwise": "ทวนเข็มนาฬิกา", "api_key": "API key", "api_key_description": "ค่านี้จะแสดงเพียงครั้งเดียว โปรดคัดลอกก่อนปิดหน้าต่าง", @@ -1004,7 +1006,6 @@ "feature_photo_updated": "อัพเดทภาพเด่นแล้ว", "features": "ฟีเจอร์", "features_setting_description": "จัดการฟีเจอร์แอป", - "file_name": "ชื่อไฟล์", "file_name_or_extension": "นามสกุลหรือชื่อไฟล์", "filename": "ชื่อไฟล์", "filetype": "ชนิดไฟล์", @@ -1018,6 +1019,9 @@ "folders": "โฟล์เดอร์", "folders_feature_description": "การเรียกดูมุมมองโฟลเดอร์สำหรับภาพถ่ายและวิดีโอในระบบไฟล์", "forward": "ไปข้างหน้า", + "free_up_space": "เพิ่มพื้นที่ว่าง", + "free_up_space_description": "เพิ่มพื้นที่ว่างโดยการย้ายรูปภาพและวิดีโอที่สำรองข้อมูลแล้วไปยังถังขยะของอุปกรณ์ของคุณ สำเนาที่อยู่บนเซิร์ฟเวอร์ยังคงอยู่อย่างปลอดภัย", + "free_up_space_settings_subtitle": "เพิ่มพื้นที่จัดเก็บอุปกรณ์", "gcast_enabled": "Google Cast", "gcast_enabled_description": "ฟีเจอร์นี้ต้องโหลดทรัพยากรจาก Google เพื่อทำงาน", "general": "ทั่วไป", @@ -1117,6 +1121,7 @@ "jobs": "งาน", "keep": "เก็บ", "keep_all": "เก็บทั้งหมด", + "keep_description": "เลือกสิ่งที่จะเก็บไว้บนอุปกรณ์ของคุณขณะเพิ่มพื้นที่ว่าง", "keep_this_delete_others": "เก็บสิ่งนี้ไว้ ลบอันอื่นออก", "kept_this_deleted_others": "เก็บเนื้อหานี้และลบ {count, plural, one {# Asset} other {# Asset}}", "keyboard_shortcuts": "ปุ่มพิมพ์ลัด", diff --git a/i18n/tr.json b/i18n/tr.json index a77e23d0a1..a334ab789e 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -572,6 +572,9 @@ "asset_list_layout_sub_title": "Düzen", "asset_list_settings_subtitle": "Fotoğraf ızgara düzeni ayarları", "asset_list_settings_title": "Fotoğraf Izgarası", + "asset_not_found_on_device_android": "Cihazda varlık bulunamadı", + "asset_not_found_on_device_ios": "Cihazınızda varlık bulunamadı. eğer icloud kullanıyorsanız, icloud'da depolanan dosyanın hatalı olması nedeniyle varlığa erişilemeyebilir.", + "asset_not_found_on_icloud": "Varlık icloud'da bulunamadı. İcloud'da depolanan dosyanın hatalı olması nedeniyle varlığa erişilemeyebilir.", "asset_offline": "Öğe Çevrim Dışı", "asset_offline_description": "Bu harici öğe artık diskte bulunmuyor. Yardım için lütfen Immich yöneticinizle iletişime geçin.", "asset_restored_successfully": "Öğe başarıyla geri yüklendi", @@ -1192,7 +1195,6 @@ "features": "Özellikler", "features_in_development": "Geliştirme Aşamasındaki Özellikler", "features_setting_description": "Uygulamanın özelliklerini yönet", - "file_name": "Dosya adı: {file_name}", "file_name_or_extension": "Dosya adı veya uzantı", "file_size": "Dosya boyutu", "filename": "Dosya adı", diff --git a/i18n/uk.json b/i18n/uk.json index 668871e902..6834d22fc7 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -1,20 +1,20 @@ { - "about": "Про програму", + "about": "Про застосунок", "account": "Обліковий запис", - "account_settings": "Налаштування профілю", + "account_settings": "Налаштування облікового запису", "acknowledge": "Прийняти", "action": "Дія", "action_common_update": "Оновити", "action_description": "Набір дій, які потрібно виконати з відфільтрованими фото та відео", "actions": "Дії", - "active": "Виконується", + "active": "Активний", "active_count": "Активні: {count}", "activity": "Активність", "activity_changed": "Активність {enabled, select, true {увімкнено} other {вимкнено}}", "add": "Додати", "add_a_description": "Додати опис", "add_a_location": "Додати місцезнаходження", - "add_a_name": "Додати Ім'я", + "add_a_name": "Додати ім'я", "add_a_title": "Додати назву", "add_action": "Додати дію", "add_action_description": "Натисніть, щоб додати дію", @@ -31,7 +31,7 @@ "add_photos": "Додати фото", "add_tag": "Додати тег", "add_to": "Додати у…", - "add_to_album": "Додати у альбом", + "add_to_album": "Додати до альбому", "add_to_album_bottom_sheet_added": "Додано до {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}", "add_to_album_bottom_sheet_some_local_assets": "Деякі локальні файли не вдалося додати до альбому", @@ -39,8 +39,8 @@ "add_to_albums": "Додати до альбомів", "add_to_albums_count": "Додати до альбомів ({count})", "add_to_bottom_bar": "Додати до", - "add_to_shared_album": "Додати у спільний альбом", - "add_upload_to_stack": "Додати завантаження до стеку", + "add_to_shared_album": "Додати до спільного альбому", + "add_upload_to_stack": "Додати вивантаження в стек", "add_url": "Додати URL", "add_workflow_step": "Додати крок робочого процесу", "added_to_archive": "Додано до архіву", @@ -49,9 +49,9 @@ "admin": { "add_exclusion_pattern_description": "Додати шаблони виключень. Підстановка з використанням *, ** та ? підтримується. Для ігнорування всіх файлів у будь-якому каталозі з ім'ям «Raw», використовуйте \"**/Raw/**\". Для ігнорування всіх файлів, що закінчуються на \".tif\", використовуйте \"**/*.tif\". Для ігнорування абсолютного шляху використовуйте \"/path/to/ignore/**\".", "admin_user": "Адміністратор", - "asset_offline_description": "Цей файл зовнішньої бібліотеки не знайдено на диску і був переміщений до смітника. Якщо файл був переміщений у межах бібліотеки, перевірте свою стрічку на наявність нового відповідного файлу. Щоб відновити цей файл, переконайтеся, що шлях до файлу доступний для Immich, і проскануйте бібліотеку.", + "asset_offline_description": "Цей файл зовнішньої бібліотеки не знайдено на диску і був переміщений до кошика. Якщо файл був переміщений у межах бібліотеки, перевірте свою стрічку на наявність нового відповідного файлу. Щоб відновити цей файл, переконайтеся, що шлях до файлу доступний для Immich, і проскануйте бібліотеку.", "authentication_settings": "Налаштування аутентифікації", - "authentication_settings_description": "Управління паролями, OAuth та іншими налаштуваннями аутентифікації", + "authentication_settings_description": "Керування паролями, OAuth та іншими налаштуваннями аутентифікації", "authentication_settings_disable_all": "Ви впевнені, що хочете вимкнути всі методи входу? Вхід буде повністю вимкнений.", "authentication_settings_reenable": "Для повторного ввімкнення використовуйте Команду сервера.", "background_task_job": "Фонові Завдання", @@ -61,7 +61,7 @@ "backup_onboarding_1_description": "віддалена копія у хмарі або в іншому фізичному місці.", "backup_onboarding_2_description": "локальні копії на різних пристроях. Це включає оригінальні фото та відео і їх локальні резервні копії.", "backup_onboarding_3_description": "загальні копії ваших даних, включаючи оригінальні фото та відео. Це включає 1 віддалену копію і 2 локальні копії.", - "backup_onboarding_description": "Рекомендовано дотримуватися стратегії резервного копіювання 3-2-1 для захисту ваших даних. Зберігайте копії завантажених фото й відео, а також бази даних Immich, щоб забезпечити повноцінний захист та відновлення.", + "backup_onboarding_description": "Рекомендовано дотримуватися стратегії резервного копіювання 3-2-1 для захисту ваших даних. Зберігайте копії вивантажених фото й відео, а також бази даних Immich, щоб забезпечити повноцінний захист та відновлення.", "backup_onboarding_footer": "Докладніше про резервне копіювання Immich можна дізнатися з документації.", "backup_onboarding_parts_title": "Резервне копіювання за стратегією 3-2-1 включає:", "backup_onboarding_title": "Резервні копії", @@ -86,7 +86,7 @@ "export_config_as_json_description": "Завантажити поточну конфігурацію системи у форматі JSON", "external_libraries_page_description": "Сторінка зовнішньої бібліотеки адміністратора", "face_detection": "Виявлення обличчя", - "face_detection_description": "Виявлення облич на зображеннях за допомогою машинного навчання. Для відео обробляється лише ескіз. \\\"Оновити\\\" повторно обробляє всі зображення. \\\"Скинути\\\" додатково очищає всі поточні дані про обличчя. \\\"Відсутні\\\" ставить у чергу зображення, які ще не були оброблені. Виявлені обличчя будуть поставлені в чергу для розпізнавання після завершення виявлення, групуючи їх у вже існуючих або нових людей.", + "face_detection_description": "Виявлення облич на зображеннях за допомогою машинного навчання. Для відео обробляється лише мініатюра. \\\"Оновити\\\" повторно обробляє всі зображення. \\\"Скинути\\\" додатково очищає всі поточні дані про обличчя. \\\"Відсутні\\\" ставить у чергу зображення, які ще не були оброблені. Виявлені обличчя будуть поставлені в чергу для розпізнавання після завершення виявлення, групуючи їх у вже існуючих або нових людей.", "facial_recognition_job_description": "Групування виявлених облич у людей. Цей крок виконується після завершення виявлення облич. \"Скинути\" повторно кластеризує всі обличчя. \"Відсутні\" ставить у чергу обличчя, яким ще не призначено людину.", "failed_job_command": "Команда {command} не виконалася для завдання: {job}", "force_delete_user_warning": "ПОПЕРЕДЖЕННЯ: Це негайно призведе до видалення користувача і всіх його файлів. Цю дію не можна скасувати, і файли не можна буде відновити.", @@ -102,24 +102,24 @@ "image_prefer_wide_gamut": "Віддавати перевагу широкій гамі", "image_prefer_wide_gamut_setting_description": "Для мініатюр використовуйте дисплей P3. Це краще зберігає яскравість зображень з широким колірним простором, але на старих пристроях зі старою версією браузера зображення можуть виглядати інакше. sRGB-зображення зберігаються у форматі sRGB, щоб уникнути зсуву кольорів.", "image_preview_description": "Зображення середнього розміру без метаданих, яке використовується при перегляді окремого зображення та для машинного навчання", - "image_preview_quality_description": "Якість попереднього перегляду від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми. Низьке значення може вплинути на якість машинного навчання.", + "image_preview_quality_description": "Якість попереднього перегляду від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи застосунку. Низьке значення може вплинути на якість машинного навчання.", "image_preview_title": "Налаштування попереднього перегляду", "image_progressive": "Прогресивний", "image_progressive_description": "Кодуйте зображення JPEG поступово для поступового завантаження відображення. Це не впливає на зображення WebP.", "image_quality": "Якість", - "image_resolution": "Роздільність", - "image_resolution_description": "Вища роздільність може зберігати більше деталей, але займає більше часу для кодування, має більші розміри файлів і може зменшити швидкість роботи програми.", + "image_resolution": "Роздільна здатність", + "image_resolution_description": "Вища роздільна здатність може зберігати більше деталей, але займає більше часу для кодування, має більші розміри файлів і може зменшити швидкість роботи застосунку.", "image_settings": "Налаштування зображення", "image_settings_description": "Керувати якістю та роздільною здатністю згенерованих зображень", "image_thumbnail_description": "Маленька мініатюра із видаленими метаданими, що використовується для перегляду груп фотографій, наприклад, на основній лінії часу", - "image_thumbnail_quality_description": "Якість мініатюри від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми.", + "image_thumbnail_quality_description": "Якість мініатюри від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи застосунку.", "image_thumbnail_title": "Налаштування мініатюр", - "import_config_from_json_description": "Імпортуйте конфігурацію системи, завантаживши файл конфігурації JSON", + "import_config_from_json_description": "Імпортуйте конфігурацію системи, вивантаживши файл конфігурації JSON", "job_concurrency": "{job} одночасно", "job_created": "Завдання створено", "job_not_concurrency_safe": "Це завдання не є безпечним для одночасного виконання.", "job_settings": "Налаштування завдань", - "job_settings_description": "Управління паралельністю завдань", + "job_settings_description": "Керування паралельністю завдань", "jobs_delayed": "{jobCount, plural, other {# відкладено}}", "jobs_failed": "{jobCount, plural, other {# не вдалося}}", "jobs_over_time": "Завдання за часом", @@ -184,7 +184,7 @@ "machine_learning_ocr_model": "Модель OCR", "machine_learning_ocr_model_description": "Серверні моделі точніші за мобільні, але обробляють дані довше та використовують більше пам'яті.", "machine_learning_settings": "Налаштування машинного навчання", - "machine_learning_settings_description": "Управління функціями та налаштуваннями машинного навчання", + "machine_learning_settings_description": "Керування функціями та налаштуваннями машинного навчання", "machine_learning_smart_search": "Розумний пошук", "machine_learning_smart_search_description": "Пошук зображень за допомогою семантичних вбудовувань CLIP", "machine_learning_smart_search_enabled": "Увімкнути розумний пошук", @@ -203,23 +203,23 @@ "maintenance_settings_description": "Переведення Immich у режим технічного обслуговування", "maintenance_start": "Перехід у режим технічного обслуговування", "maintenance_start_error": "Не вдалося запустити режим обслуговування.", - "maintenance_upload_backup": "Завантажити файл резервної копії бази даних", - "maintenance_upload_backup_error": "Не вдалося завантажити резервну копію, це файл .sql/.sql.gz?", + "maintenance_upload_backup": "Вивантажити файл резервної копії бази даних", + "maintenance_upload_backup_error": "Не вдалося вивантажити резервну копію, це файл .sql/.sql.gz?", "manage_concurrency": "Керування паралельністю завдань", "manage_concurrency_description": "Перехід до сторінки завдань для керування паралельністю", "manage_log_settings": "Керування налаштуваннями журналу", "map_dark_style": "Темний стиль", "map_enable_description": "Увімкнути функції мапи", - "map_gps_settings": "Налаштування карти та геолокації", - "map_gps_settings_description": "Керування налаштуваннями карти та геолокації (зворотний геокодинг)", - "map_implications": "Функція карти використовує зовнішній сервіс плиток (tiles.immich.cloud)", + "map_gps_settings": "Налаштування мапи та геолокації", + "map_gps_settings_description": "Керування налаштуваннями мапи та геолокації (зворотний геокодинг)", + "map_implications": "Функція мапи використовує зовнішній сервіс плиток (tiles.immich.cloud)", "map_light_style": "Світлий стиль", "map_manage_reverse_geocoding_settings": "Керувати налаштуваннями зворотного геокодування", "map_reverse_geocoding": "Зворотне геокодування", "map_reverse_geocoding_enable_description": "Увімкнути зворотне геокодування", "map_reverse_geocoding_settings": "Налаштування зворотного геокодування", "map_settings": "Мапа", - "map_settings_description": "Управління налаштуваннями мапи", + "map_settings_description": "Керування налаштуваннями мапи", "map_style_description": "URL до теми мапи у форматі style.json", "memory_cleanup_job": "Очищення спогадів", "memory_generate_job": "Генерація спогадів", @@ -247,10 +247,10 @@ "nightly_tasks_sync_quota_usage_setting_description": "Оновити квоту сховища користувача на основі поточного використання", "no_paths_added": "Шляхи не додано", "no_pattern_added": "Шаблон не додано", - "note_apply_storage_label_previous_assets": "Примітка: Щоб застосувати мітку зберігання до раніше завантажених файлів, запустити", + "note_apply_storage_label_previous_assets": "Примітка: Щоб застосувати мітку зберігання до раніше вивантажених файлів, запустити", "note_cannot_be_changed_later": "ПРИМІТКА: Це не можна змінити пізніше!", - "notification_email_from_address": "Адреса відправника", - "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \". Переконайтеся, що використовуєте адресу, з якої вам дозволено надсилати листи.", + "notification_email_from_address": "Адреса надсилача", + "notification_email_from_address_description": "Адреса електронної пошти надсилача, наприклад: \"Immich Photo Server \". Переконайтеся, що використовуєте адресу, з якої вам дозволено надсилати листи.", "notification_email_host_description": "Адреса поштового сервера (наприклад, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ігнорувати помилки сертифіката", "notification_email_ignore_certificate_errors_description": "Ігнорувати помилки перевірки сертифікатів TLS (не рекомендується)", @@ -262,11 +262,11 @@ "notification_email_setting_description": "Налаштування для надсилання email-повідомлень", "notification_email_test_email": "Надіслати тестовий лист", "notification_email_test_email_failed": "Не вдалося надіслати тестовий лист. Перевірте ваші значення", - "notification_email_test_email_sent": "Тестовий лист був відправлений на {email}. Будь ласка, перевірте свою скриньку вхідних.", + "notification_email_test_email_sent": "Тестовий лист було надіслано на {email}. Будь ласка, перевірте свою скриньку вхідних.", "notification_email_username_description": "Ім'я користувача для автентифікації на поштовому сервері", "notification_enable_email_notifications": "Увімкнути сповіщення електронною поштою", "notification_settings": "Налаштування сповіщень", - "notification_settings_description": "Управління налаштуваннями сповіщень, включно із електронною поштою", + "notification_settings_description": "Керування налаштуваннями сповіщень, включно із електронною поштою", "oauth_auto_launch": "Автозапуск", "oauth_auto_launch_description": "Автоматично запускати процес входу через OAuth при переході на сторінку входу", "oauth_auto_register": "Автоматична реєстрація", @@ -282,7 +282,7 @@ "oauth_settings": "OAuth", "oauth_settings_description": "Керування налаштуваннями входу через OAuth", "oauth_settings_more_details": "Для отримання додаткової інформації про цю функцію, зверніться до документації.", - "oauth_storage_label_claim": "Тег директорії сховища", + "oauth_storage_label_claim": "Тег папки сховища", "oauth_storage_label_claim_description": "Автоматично встановити мітку зберігання користувача на значення цієї вимоги.", "oauth_storage_quota_claim": "Заявка на квоту на зберігання", "oauth_storage_quota_claim_description": "Автоматично встановити квоту сховища користувача на значення цієї вимоги.", @@ -330,17 +330,17 @@ "storage_template_hash_verification_enabled": "Увімкнено перевірку хешу", "storage_template_hash_verification_enabled_description": "Увімкнути перевірку хеша. Не вимикайте це, якщо ви не впевнені в наслідках", "storage_template_migration": "Міграція шаблонів сховища", - "storage_template_migration_description": "Застосувати поточний {template} до раніше завантажених файлів", - "storage_template_migration_info": "Шаблон зберігання конвертуватиме всі розширення у нижній регістр. Зміни шаблону застосовуватимуться лише до нових файлів. Щоб застосувати шаблон до раніше завантажених файлів, запустіть {job}.", + "storage_template_migration_description": "Застосувати поточний {template} до раніше вивантажених файлів", + "storage_template_migration_info": "Шаблон зберігання конвертуватиме всі розширення у нижній регістр. Зміни шаблону застосовуватимуться лише до нових файлів. Щоб застосувати шаблон до раніше вивантажених файлів, запустіть {job}.", "storage_template_migration_job": "Завдання міграції шаблону зберігання", "storage_template_more_details": "Для отримання детальнішої інформації про цю функцію, звертайтесь до Шаблону зберігання та його наслідків", "storage_template_onboarding_description_v2": "Якщо цю функцію увімкнено, файли будуть автоматично впорядковуватися за шаблоном, визначеним користувачем. Докладніше дивіться в документації.", "storage_template_path_length": "Приблизна максимальна довжина шляху: {length, number}/{limit, number}", "storage_template_settings": "Шаблон сховища", - "storage_template_settings_description": "Керування структурою папок та іменами завантажених файлів", + "storage_template_settings_description": "Керування структурою папок та іменами вивантажених файлів", "storage_template_user_label": "{label} - це мітка зберігання користувача", "system_settings": "Системні налаштування", - "tag_cleanup_job": "Очистити тег", + "tag_cleanup_job": "Очищення тегів", "template_email_available_tags": "Ви можете використовувати наступні змінні у своєму шаблоні: {tags}", "template_email_if_empty": "Якщо шаблон порожній, буде використано стандартний електронний лист.", "template_email_invite_album": "Шаблон запрошення до альбому", @@ -357,7 +357,7 @@ "thumbnail_generation_job": "Створення мініатюр", "thumbnail_generation_job_description": "Створити великі, малі та розмиті мініатюри для кожного фото та відео, а також мініатюри для кожної особи", "transcoding_acceleration_api": "API прискорення", - "transcoding_acceleration_api_description": "API, яка буде взаємодіяти з вашим пристроєм для прискорення транскодування. Ця настройка працює у \"найкращих умовах\" і, в разі невдачі, перейде на програмне транскодування. Підтримка VP9 може або не може працювати, залежно від вашого обладнання.", + "transcoding_acceleration_api_description": "API, яка буде взаємодіяти з вашим пристроєм для прискорення транскодування. Це налаштування працює у \"найкращих умовах\" і, в разі невдачі, перейде на програмне транскодування. Підтримка VP9 може або не може працювати, залежно від вашого обладнання.", "transcoding_acceleration_nvenc": "NVENC (вимагає графічного процесора NVIDIA)", "transcoding_acceleration_qsv": "Швидка синхронізація (потрібен процесор Intel 7-го покоління або новішої версії)", "transcoding_acceleration_rkmpp": "RKMPP (тільки на SOC Rockchip)", @@ -368,14 +368,14 @@ "transcoding_accepted_containers_description": "Виберіть, які формати контейнерів не потрібно перетворювати в MP4. Використовується лише для певних політик перекодування.", "transcoding_accepted_video_codecs": "Прийняті відеокодеки", "transcoding_accepted_video_codecs_description": "Виберіть відеокодеки, які не потребують транскодування. Використовується лише для певних політик транскодування.", - "transcoding_advanced_options_description": "Опції, які більшості користувачів не потрібно змінювати", + "transcoding_advanced_options_description": "Параметри, які більшості користувачів не потрібно змінювати", "transcoding_audio_codec": "Аудіокодек", "transcoding_audio_codec_description": "Opus - це опція найвищої якості, але менше сумісна зі старими пристроями або програмним забезпеченням.", "transcoding_bitrate_description": "Відео з бітрейтом вище максимального або не в прийнятому форматі", "transcoding_codecs_learn_more": "Для отримання додаткової інформації про термінологію, що використовується тут, звертайтеся до документації FFmpeg для кодеків H.264, HEVC та VP9.", "transcoding_constant_quality_mode": "Режим постійної якості", - "transcoding_constant_quality_mode_description": "ICQ краще, ніж CQP, але деякі пристрої апаратного прискорення не підтримують цей режим. Встановлення цієї опції буде віддавати перевагу зазначеному режиму під час кодування на основі якості. Ігнорується NVENC, оскільки він не підтримує ICQ.", - "transcoding_constant_rate_factor": "Коефіцієнт постійної ставки (-crf)", + "transcoding_constant_quality_mode_description": "ICQ краще, ніж CQP, але деякі пристрої апаратного прискорення не підтримують цей режим. Встановлення цього параметра буде віддавати перевагу зазначеному режиму під час кодування на основі якості. Ігнорується NVENC, оскільки він не підтримує ICQ.", + "transcoding_constant_rate_factor": "Коефіцієнт постійної якості (-crf)", "transcoding_constant_rate_factor_description": "Рівень якості відео. Зазвичай значення для H.264 - 23, HEVC - 28, VP9 - 31, AV1 - 35. Нижче значення краще, але створює більші файли.", "transcoding_disabled_description": "Без транскодування відео — може призвести до проблем з відтворенням на деяких клієнтах", "transcoding_encoding_options": "Параметри кодування", @@ -416,11 +416,11 @@ "transcoding_two_pass_encoding_setting_description": "Транскодування за двома проходами для отримання кращих закодованих відео. Коли ввімкнено максимальний бітрейт (необхідний для роботи з H.264 та HEVC), цей режим використовує діапазон бітрейту, заснований на максимальному бітрейті, і ігнорує CRF. Для VP9 можна використовувати CRF, якщо вимкнено максимальний бітрейт.", "transcoding_video_codec": "Відеокодек", "transcoding_video_codec_description": "VP9 має високу ефективність і сумісність з вебом, але потребує більше часу на транскодування. HEVC працює схоже, але має меншу сумісність з вебом. H.264 має широку сумісність і швидко транскодується, але створює значно більші файли. AV1 - найефективніший кодек, але не підтримується на старіших пристроях.", - "trash_enabled_description": "Увімкнення смітника", + "trash_enabled_description": "Увімкнення кошика", "trash_number_of_days": "Кількість днів", - "trash_number_of_days_description": "Кількість днів, протягом яких залишати файли у смітнику перед їх остаточним видаленням", - "trash_settings": "Налаштування смітника", - "trash_settings_description": "Керування налаштуваннями смітника", + "trash_number_of_days_description": "Кількість днів, протягом яких залишати файли у кошику перед їх остаточним видаленням", + "trash_settings": "Налаштування кошика", + "trash_settings_description": "Керування налаштуваннями кошика", "unlink_all_oauth_accounts": "Від’єднати всі облікові записи OAuth", "unlink_all_oauth_accounts_description": "Не забудьте від’єднати всі облікові записи OAuth перед переходом до нового постачальника.", "unlink_all_oauth_accounts_prompt": "Ви впевнені, що хочете від’єднати всі облікові записи OAuth? Це скине ідентифікатор OAuth для кожного користувача, і цю дію не можна буде скасувати.", @@ -447,7 +447,7 @@ "video_conversion_job": "Перекодувати відео", "video_conversion_job_description": "Транскодувати відео для ширшої сумісності з браузерами та пристроями" }, - "admin_email": "Email Адміністратора", + "admin_email": "Електронна пошта адміністратора", "admin_password": "Пароль адміністратора", "administration": "Адміністрування", "advanced": "Розширені", @@ -457,7 +457,7 @@ "advanced_settings_enable_alternate_media_filter_subtitle": "Використовуйте цей варіант для фільтрації файлів під час синхронізації за альтернативними критеріями. Спробуйте це, якщо у вас виникають проблеми з тим, що застосунок не виявляє всі альбоми.", "advanced_settings_enable_alternate_media_filter_title": "[ЕКСПЕРИМЕНТАЛЬНИЙ] Використовуйте альтернативний фільтр синхронізації альбомів пристрою", "advanced_settings_log_level_title": "Рівень журналювання: {level}", - "advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із файлів на пристрої. Активуйте цей параметр, щоб завантажувати зображення з серверу.", + "advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із файлів на пристрої. Увімкніть цей параметр, щоб завантажувати зображення з серверу.", "advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням", "advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом", "advanced_settings_proxy_headers_title": "Користувацькі проксі-заголовки [ЕКСПЕРИМЕНТАЛЬНА ВЕРСІЯ]", @@ -475,7 +475,7 @@ "age_years": "{years, plural, other {Вік #}}", "album": "Альбом", "album_added": "Альбом додано", - "album_added_notification_setting_description": "Отримувати повідомлення по електронній пошті, коли вас додають до спільного альбому", + "album_added_notification_setting_description": "Отримувати сповіщення електронною поштою, коли вас додають до спільного альбому", "album_cover_updated": "Обкладинка альбому оновлена", "album_delete_confirmation": "Ви впевнені, що хочете видалити альбом {album}?", "album_delete_confirmation_description": "Якщо альбом був спільним, інші користувачі не зможуть отримати доступ до нього.", @@ -486,7 +486,7 @@ "album_leave": "Залишити альбом?", "album_leave_confirmation": "Ви впевнені, що хочете залишити альбом {album}?", "album_name": "Назва Альбому", - "album_options": "Опції альбому", + "album_options": "Параметри альбому", "album_remove_user": "Видалити користувача?", "album_remove_user_confirmation": "Ви впевнені, що хочете видалити {user}?", "album_search_not_found": "Альбомів, що відповідають вашому запиту, не знайдено", @@ -495,7 +495,7 @@ "album_summary": "Короткий опис альбому", "album_updated": "Альбом оновлено", "album_updated_setting_description": "Отримуйте сповіщення на електронну пошту, коли у спільному альбомі з'являються нові фото та відео", - "album_upload_assets": "Завантажте фото та відео зі свого комп'ютера та додайте їх до альбому", + "album_upload_assets": "Вивантажте фото та відео зі свого комп'ютера та додайте їх до альбому", "album_user_left": "Ви покинули {album}", "album_user_removed": "Користувач {user} видалений", "album_viewer_appbar_delete_confirm": "Ви впевнені, що хочете видалити цей альбом зі свого облікового запису?", @@ -506,14 +506,14 @@ "album_viewer_appbar_share_leave": "Вийти з альбому", "album_viewer_appbar_share_to": "Поділитися", "album_viewer_page_share_add_users": "Додати користувачів", - "album_with_link_access": "Поділіться посиланням на альбом, щоб ваші друзі могли його переглянути.", + "album_with_link_access": "Будь-хто з посиланням може переглядати фото та відео в цьому альбомі.", "albums": "Альбоми", "albums_count": "{count, plural, one {1 альбом} few {{count, number} альбоми} many {{count, number} альбомів} other {{count, number} альбомів}}", "albums_default_sort_order": "Порядок сортування альбомів за замовчуваням", "albums_default_sort_order_description": "Початковий порядок сортування файлів під час створення нових альбомів.", "albums_feature_description": "Колекції файлів, які можна спільно використовувати з іншими користувачами.", "albums_on_device_count": "Альбоми на пристрої ({count})", - "albums_selected": "{count, plural, one {# вибрано альбом} other {# вибрані альбоми}}", + "albums_selected": "{count, plural, one {# альбом вибрано} few {# альбоми вибрано} many {# альбомів вибрано} other {# альбомів вибрано}}", "all": "Усі", "all_albums": "Усі альбоми", "all_people": "Усі люди", @@ -522,10 +522,10 @@ "allow_dark_mode": "Дозволити темний режим", "allow_edits": "Дозволити редагування", "allow_public_user_to_download": "Дозволити публічному користувачеві завантажувати файли", - "allow_public_user_to_upload": "Дозволити публічним користувачам завантажувати", + "allow_public_user_to_upload": "Дозволити публічним користувачам вивантажувати", "allowed": "Дозволено", "alt_text_qr_code": "Зображення QR-коду", - "always_keep": "Завжди тримайте", + "always_keep": "Завжди зберігати", "always_keep_photos_hint": "Функція «Звільнити місце» збереже всі фотографії на цьому пристрої.", "always_keep_videos_hint": "Функція «Звільнити місце» збереже всі відео на цьому пристрої.", "anti_clockwise": "Проти годинникової стрілки", @@ -537,10 +537,10 @@ "app_bar_signout_dialog_content": "Ви впевнені, що хочете вийти з облікового запису?", "app_bar_signout_dialog_ok": "Так", "app_bar_signout_dialog_title": "Вийти", - "app_download_links": "Посилання для завантаження додатків", - "app_settings": "Налаштування програми", - "app_stores": "Магазини додатків", - "app_update_available": "Оновлення програми доступне", + "app_download_links": "Посилання для завантаження застосунків", + "app_settings": "Налаштування застосунку", + "app_stores": "Магазини застосунків", + "app_update_available": "Оновлення застосунку доступне", "appears_in": "З'являється в", "apply_count": "Застосувати ({count, number})", "archive": "Архівувати", @@ -572,43 +572,43 @@ "asset_list_layout_sub_title": "Розмітка", "asset_list_settings_subtitle": "Налаштування вигляду сітки фото", "asset_list_settings_title": "Фото-сітка", - "asset_not_found_on_device_android": "Ресурс не знайдено на пристрої", - "asset_not_found_on_device_ios": "Ресурс не знайдено на пристрої. Якщо ви використовуєте iCloud, ресурс може бути недоступним через пошкоджений файл, що зберігається в iCloud", - "asset_not_found_on_icloud": "Ресурс не знайдено в iCloud. Можливо, ресурс недоступний через пошкоджений файл, що зберігається в iCloud", + "asset_not_found_on_device_android": "Файл не знайдено на пристрої", + "asset_not_found_on_device_ios": "Файл не знайдено на пристрої. Якщо ви використовуєте iCloud, файл може бути недоступним через пошкоджений файл, що зберігається в iCloud", + "asset_not_found_on_icloud": "Файл не знайдено в iCloud. Можливо, файл недоступний через пошкоджений файл, що зберігається в iCloud", "asset_offline": "Файл недоступний", "asset_offline_description": "Цей файл не знайдено на диску. Будь ласка, зверніться до адміністратора Immich за допомогою.", "asset_restored_successfully": "Файл успішно відновлено", "asset_skipped": "Пропущено", - "asset_skipped_in_trash": "У смітнику", + "asset_skipped_in_trash": "У кошику", "asset_trashed": "Файл видалено", "asset_troubleshoot": "Вирішення проблем з файлами", - "asset_uploaded": "Завантажено", - "asset_uploading": "Завантаження…", + "asset_uploaded": "Вивантажено", + "asset_uploading": "Вивантаження…", "asset_viewer_settings_subtitle": "Налаштування переглядача галереї", "asset_viewer_settings_title": "Переглядач зображень", "assets": "файли", "assets_added_count": "Додано {count, plural, one {# файл} few {# файли} other {# файлів}}", "assets_added_to_album_count": "Додано {count, plural, one {# файл} few {# файли} other {# файлів}} до альбому", - "assets_added_to_albums_count": "Додано {assetTotal, plural, one {# файл} other {# файли}} до {albumTotal, plural, one {# альбом} other {# альбом}}", - "assets_cannot_be_added_to_album_count": "{count, plural, one {Файл} other {Файли}} не можна додати до альбому", - "assets_cannot_be_added_to_albums": "{count, plural, one {Файл} other {Файли}} не можна додати до жодного з альбомів", + "assets_added_to_albums_count": "Додано {assetTotal, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}} до {albumTotal, plural, one {# альбому} few {# альбомів} many {# альбомів} other {# альбомів}}", + "assets_cannot_be_added_to_album_count": "{count, plural, one {Файл} few {Файли} many {Файли} other {Файли}} не можна додати до альбому", + "assets_cannot_be_added_to_albums": "{count, plural, one {Файл} few {Файли} many {Файлів} other {Файлів}} не можна додати до жодного з альбомів", "assets_count": "{count, plural, one {# файл} few {# файли} other {# файлів}}", "assets_deleted_permanently": "Остаточно видалено {count, plural, one {# файл} few {# файли} other {# файлів}}", "assets_deleted_permanently_from_server": "Видалено назавжди {count, plural, one {# файл} few {# файли} other {# файлів}} з сервера Immich", - "assets_downloaded_failed": "{count, plural, one {Завантажено # файл — {error} файл не вдалося} other {Завантажено # файлів — {error} файлів не вдалося}}", - "assets_downloaded_successfully": "{count, plural, one {Успішно завантажено # файл} other {Успішно завантажено # файлів}}", - "assets_moved_to_trash_count": "Переміщено {count, plural, one {# файл} few {# файли} other {# файлів}} до смітника", + "assets_downloaded_failed": "{count, plural, one {Завантажено # файл — {error} не вдалося} few {Завантажено # файли — {error} не вдалося} many {Завантажено # файлів — {error} не вдалося} other {Завантажено # файлів — {error} не вдалося}}", + "assets_downloaded_successfully": "{count, plural, one {Успішно завантажено # файл} few {Успішно завантажено # файли} many {Успішно завантажено # файлів} other {Успішно завантажено # файлів}}", + "assets_moved_to_trash_count": "Переміщено {count, plural, one {# файл} few {# файли} other {# файлів}} до кошика", "assets_permanently_deleted_count": "Остаточно видалено {count, plural, one {# файл} few {# файли} other {# файлів}}", "assets_removed_count": "Вилучено {count, plural, one {# файл} few {# файли} other {# файлів}}", "assets_removed_permanently_from_device": "Назавжди вилучено з вашого пристрою {count, plural, one {# файл} few {# файли} other {# файлів}}", - "assets_restore_confirmation": "Ви впевнені, що хочете відновити всі свої файли зі смітника? Цю дію не можна скасувати! Зверніть увагу, що недоступні файли не можуть бути відновлені таким чином.", + "assets_restore_confirmation": "Ви впевнені, що хочете відновити всі свої файли з кошика? Цю дію не можна скасувати! Зверніть увагу, що недоступні файли не можуть бути відновлені таким чином.", "assets_restored_count": "Відновлено {count, plural, one {# файл} few {# файли} other {# файлів}}", "assets_restored_successfully": "Успішно відновлено {count, plural, one {# файл} few {# файли} other {# файлів}}", - "assets_trashed": "Переміщено до смітника {count, plural, one {# файл} few {# файли} other {# файлів}}", - "assets_trashed_count": "Переміщено до смітника {count, plural, one {# файл} few {# файли} other {# файлів}}", - "assets_trashed_from_server": "Переміщено до смітника на сервері Immich {count, plural, one {# файл} few {# файли} other {# файлів}}", + "assets_trashed": "Переміщено до кошика {count, plural, one {# файл} few {# файли} other {# файлів}}", + "assets_trashed_count": "Переміщено до кошика {count, plural, one {# файл} few {# файли} other {# файлів}}", + "assets_trashed_from_server": "Переміщено до кошика на сервері Immich {count, plural, one {# файл} few {# файли} other {# файлів}}", "assets_were_part_of_album_count": "{count, plural, one {Файл був} few {Файли були} other {Файли були}} вже частиною альбому", - "assets_were_part_of_albums_count": "{count, plural, one {Файл вже був} other {Файли вже були}} частиною альбомів", + "assets_were_part_of_albums_count": "{count, plural, one {Файл вже був} few {Файли вже були} many {Файлів вже були} other {Файлів вже були}} частиною альбомів", "authorized_devices": "Авторизовані пристрої", "automatic_endpoint_switching_subtitle": "Підключатися локально через зазначену Wi-Fi мережу, коли це можливо, і використовувати альтернативні з'єднання в інших випадках", "automatic_endpoint_switching_title": "Автоматичне перемикання URL", @@ -621,8 +621,8 @@ "background_options": "Параметри фону", "backup": "Резервне копіювання", "backup_album_selection_page_albums_device": "Альбоми на пристрої ({count})", - "backup_album_selection_page_albums_tap": "Торкніться, щоб включити, двічі, щоб виключити", - "backup_album_selection_page_assets_scatter": "Файли можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.", + "backup_album_selection_page_albums_tap": "Торкніться, щоб додати, двічі, щоб вилучити", + "backup_album_selection_page_assets_scatter": "Файли можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути додані або вилучені під час резервного копіювання.", "backup_album_selection_page_select_albums": "Оберіть альбоми", "backup_album_selection_page_selection_info": "Інформація про обране", "backup_album_selection_page_total_assets": "Загальна кількість унікальних файлів", @@ -631,14 +631,14 @@ "backup_background_service_backup_failed_message": "Не вдалося зробити резервну копію файлів. Повторюю…", "backup_background_service_complete_notification": "Резервне копіювання файлів завершено", "backup_background_service_connection_failed_message": "Не вдалося зв'язатися із сервером. Повторюю…", - "backup_background_service_current_upload_notification": "Завантажується {filename}", + "backup_background_service_current_upload_notification": "Вивантажується {filename}", "backup_background_service_default_notification": "Перевіряю наявність нових файлів…", "backup_background_service_error_title": "Помилка резервного копіювання", "backup_background_service_in_progress_notification": "Резервне копіювання ваших файлів…", - "backup_background_service_upload_failure_notification": "Не вдалося завантажити {filename}", + "backup_background_service_upload_failure_notification": "Не вдалося вивантажити {filename}", "backup_controller_page_albums": "Резервне копіювання альбомів", - "backup_controller_page_background_app_refresh_disabled_content": "Для фонового резервного копіювання увімкніть фонове оновлення в меню \"Налаштування > Загальні > Фонове оновлення програми\".", - "backup_controller_page_background_app_refresh_disabled_title": "Фонове оновлення програми вимкнене", + "backup_controller_page_background_app_refresh_disabled_content": "Для фонового резервного копіювання увімкніть фонове оновлення в меню \"Налаштування > Загальні > Фонове оновлення застосунку\".", + "backup_controller_page_background_app_refresh_disabled_title": "Фонове оновлення застосунку вимкнене", "backup_controller_page_background_app_refresh_enable_button_text": "Перейти до налаштувань", "backup_controller_page_background_battery_info_link": "Показати як", "backup_controller_page_background_battery_info_message": "Для найкращого фонового резервного копіювання вимкніть будь-яку оптимізацію акумулятора, яка обмежує фонову активність для Immich.\n\nСпосіб залежить від конкретного пристрою, тому шукайте необхідну інформацію у виробника вашого пристрою.", @@ -647,7 +647,7 @@ "backup_controller_page_background_charging": "Лише під час заряджання", "backup_controller_page_background_configure_error": "Не вдалося налаштувати фоновий сервіс", "backup_controller_page_background_delay": "Затримка резервного копіювання нових файлів: {duration}", - "backup_controller_page_background_description": "Увімкніть фонову службу, щоб автоматично створювати резервні копії будь-яких нових файлів без необхідності відкривати програму", + "backup_controller_page_background_description": "Увімкніть фонову службу, щоб автоматично створювати резервні копії будь-яких нових файлів без необхідності відкривати застосунок", "backup_controller_page_background_is_off": "Автоматичне фонове резервне копіювання вимкнено", "backup_controller_page_background_is_on": "Автоматичне фонове резервне копіювання ввімкнено", "backup_controller_page_background_turn_off": "Вимкнути фоновий сервіс", @@ -657,7 +657,7 @@ "backup_controller_page_backup_selected": "Обрано: ", "backup_controller_page_backup_sub": "Резервні копії фото та відео", "backup_controller_page_created": "Створено: {date}", - "backup_controller_page_desc_backup": "Увімкніть резервне копіювання на передньому плані, щоб автоматично завантажувати нові фото та відео на сервер під час відкриття програми.", + "backup_controller_page_desc_backup": "Увімкніть резервне копіювання на передньому плані, щоб автоматично вивантажувати нові фото та відео на сервер під час відкриття застосунку.", "backup_controller_page_excluded": "Вилучено: ", "backup_controller_page_failed": "Невдалі ({count})", "backup_controller_page_filename": "Назва файлу: {filename} [{size}]", @@ -675,18 +675,18 @@ "backup_controller_page_total_sub": "Усі унікальні фото та відео з вибраних альбомів", "backup_controller_page_turn_off": "Вимкнути резервне копіювання в активному режимі", "backup_controller_page_turn_on": "Увімкнути резервне копіювання в активному режимі", - "backup_controller_page_uploading_file_info": "Завантажую інформацію про файл", + "backup_controller_page_uploading_file_info": "Вивантажую інформацію про файл", "backup_err_only_album": "Не можу видалити єдиний альбом", "backup_error_sync_failed": "Помилка синхронізації. Не вдається обробити резервну копію.", "backup_info_card_assets": "файли", "backup_manual_cancelled": "Скасовано", - "backup_manual_in_progress": "Завантаження вже відбувається. Спробуйте згодом", + "backup_manual_in_progress": "Вивантаження вже відбувається. Спробуйте згодом", "backup_manual_success": "Успіх", - "backup_manual_title": "Стан завантаження", + "backup_manual_title": "Стан вивантаження", "backup_options": "Налаштування резервного копіювання", "backup_options_page_title": "Резервне копіювання", - "backup_setting_subtitle": "Управління налаштуваннями завантаження у фоновому та активному режимі", - "backup_settings_subtitle": "Керування налаштуваннями завантаження", + "backup_setting_subtitle": "Керування налаштуваннями вивантаження у фоновому та активному режимі", + "backup_settings_subtitle": "Керування налаштуваннями вивантаження", "backup_upload_details_page_more_details": "Натисніть, щоб дізнатися більше", "backward": "Назад", "biometric_auth_enabled": "Біометрична автентифікація увімкнена", @@ -699,12 +699,12 @@ "bugs_and_feature_requests": "Помилки та Запити", "build": "Збірка", "build_image": "Версія збірки", - "bulk_delete_duplicates_confirmation": "Ви впевнені, що хочете масово видалити {count, plural, one {# дубльований файл} few {# дубльовані файли} other {# дубльованих файлів}}? Це дія залишить найбільший файл у кожній групі і остаточно видалить всі інші дублікати. Цю дію неможливо скасувати!", + "bulk_delete_duplicates_confirmation": "Ви впевнені, що хочете масово видалити {count, plural, one {# дубльований файл} few {# дубльовані файли} other {# дубльованих файлів}}? Ця дія залишить найбільший файл у кожній групі і остаточно видалить всі інші дублікати. Цю дію неможливо скасувати!", "bulk_keep_duplicates_confirmation": "Ви впевнені, що хочете залишити {count, plural, one {# дубльований файл} few {# дубльовані файли} other {# дубльованих файлів}}? Це дозволить вирішити всі групи дублікатів без видалення чого-небудь.", - "bulk_trash_duplicates_confirmation": "Ви впевнені, що хочете перемістити до смітника {count, plural, one {# дубльований файл} few {# дубльовані файли} other {# дубльованих файлів}}? Це залишить найбільший файл у кожній групі й перемістить до смітника всі інші дублікати.", + "bulk_trash_duplicates_confirmation": "Ви впевнені, що хочете перемістити до кошика {count, plural, one {# дубльований файл} few {# дубльовані файли} other {# дубльованих файлів}}? Це залишить найбільший файл у кожній групі й перемістить до кошика всі інші дублікати.", "buy": "Придбати Immich", "cache_settings_clear_cache_button": "Очистити кеш", - "cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.", + "cache_settings_clear_cache_button_title": "Очищає кеш застосунку. Це суттєво знизить продуктивність застосунку, доки кеш не буде перебудовано.", "cache_settings_duplicated_assets_clear_button": "ОЧИСТИТИ", "cache_settings_duplicated_assets_subtitle": "Фото та відео, які ігноруються застосунком", "cache_settings_duplicated_assets_title": "Дубльовані фото та відео ({count})", @@ -764,12 +764,12 @@ "cleanup_deleted_assets": "Переміщено {count, plural, one {# файл} few {# файли} other {# файлів}} до кошика пристрою", "cleanup_deleting": "Переміщення до кошика...", "cleanup_found_assets": "Знайдено {count} резервних копій файлів", - "cleanup_found_assets_with_size": "Знайдено {count} резервних копій ресурсів ({size})", + "cleanup_found_assets_with_size": "Знайдено {count} резервних копій файлів ({size})", "cleanup_icloud_shared_albums_excluded": "Спільні альбоми iCloud виключаються зі сканування", - "cleanup_no_assets_found": "Не знайдено ресурсів, що відповідають наведеним вище критеріям. Функція «Звільнити місце» може видалити лише ресурси, резервні копії яких було створено на сервері", + "cleanup_no_assets_found": "Не знайдено файлів, що відповідають наведеним вище критеріям. Функція «Звільнити місце» може видалити лише файли, резервні копії яких було створено на сервері", "cleanup_preview_title": "Фото та відео для вилучення ({count})", - "cleanup_step3_description": "Скануйте резервні копії ресурсів, що відповідають вашій даті, та збережіть налаштування.", - "cleanup_step4_summary": "{count} ресурсів (створених до {date}) для видалення з вашого локального пристрою. Фотографії залишатимуться доступними з застосунку Immich.", + "cleanup_step3_description": "Скануйте резервні копії файлів, що відповідають вашій даті, та збережіть налаштування.", + "cleanup_step4_summary": "{count} файлів (створених до {date}) для видалення з вашого локального пристрою. Фотографії залишатимуться доступними із застосунку Immich.", "cleanup_trash_hint": "Щоб повністю звільнити місце для зберігання, відкрийте системну галерею та очистіть кошик", "clear": "Очистити", "clear_all": "Очистити все", @@ -819,7 +819,7 @@ "control_bottom_app_bar_edit_time": "Редагувати дату та час", "control_bottom_app_bar_share_link": "Поділитися", "control_bottom_app_bar_share_to": "Поділитися", - "control_bottom_app_bar_trash_from_immich": "До смітника", + "control_bottom_app_bar_trash_from_immich": "До кошика", "copied_image_to_clipboard": "Зображення скопійовано в буфер обміну.", "copied_to_clipboard": "Скопійовано в буфер обміну!", "copy_error": "Помилка копіювання", @@ -868,8 +868,8 @@ "custom_locale_description": "Форматувати дати та числа з урахуванням мови та регіону", "custom_url": "Власна URL-адреса", "cutoff_date_description": "Збережіть фотографії з останнього…", - "cutoff_day": "{count, plural, one {день} other {дні}}", - "cutoff_year": "{count, plural, one {рік} other {роки}}", + "cutoff_day": "{count, plural, one {день} few {дні} many {днів} other {днів}}", + "cutoff_year": "{count, plural, one {рік} few {роки} many {років} other {років}}", "daily_title_text_date": "Е, МММ дд", "daily_title_text_date_year": "Е, МММ дд, рррр", "dark": "Темна", @@ -888,10 +888,10 @@ "deduplication_criteria_2": "Кількість даних EXIF", "deduplication_info": "Інформація про дедуплікацію", "deduplication_info_description": "Для автоматичного попереднього вибору файлів і масового видалення дублікатів ми враховуємо:", - "default_locale": "Дата і час за замовчуванням", + "default_locale": "Мова та регіон за замовчуванням", "default_locale_description": "Форматувати дати та числа з урахуванням мови вашого браузера", "delete": "Видалити", - "delete_action_confirmation_message": "Ви впевнені, що хочете видалити цей файл? Його буде переміщено до смітника на сервері, а також зʼявиться запит на його видалення з пристрою", + "delete_action_confirmation_message": "Ви впевнені, що хочете видалити цей файл? Його буде переміщено до кошика на сервері, а також зʼявиться запит на його видалення з пристрою", "delete_action_prompt": "Видалено {count, plural, one {# файл} few {# файли} other {# файлів}}", "delete_album": "Видалити альбом", "delete_api_key_prompt": "Ви впевнені, що хочете видалити цей ключ API?", @@ -917,7 +917,7 @@ "delete_tag": "Видалити Тег", "delete_tag_confirmation_prompt": "Ви впевнені, що хочете видалити тег {tagName}?", "delete_user": "Видалити користувача", - "deleted_shared_link": "Видалено загальне посилання", + "deleted_shared_link": "Видалено спільне посилання", "deletes_missing_assets": "Видаляє файли, які відсутні на диску", "description": "Опис", "description_input_hint_text": "Додати опис...", @@ -928,7 +928,7 @@ "disable": "Вимкнути", "disabled": "Вимкнено", "disallow_edits": "Заборонити редагування", - "discord": "Discord'", + "discord": "Discord", "discover": "Виявити", "discovered_devices": "Виявлені пристрої", "dismiss_all_errors": "Пропустити всі помилки", @@ -963,7 +963,7 @@ "downloading_asset_filename": "Завантаження файлу {filename}", "downloading_from_icloud": "Завантаження з iCloud", "downloading_media": "Завантаження медіа", - "drop_files_to_upload": "Перенесіть файли в будь-яке місце для завантаження", + "drop_files_to_upload": "Перенесіть файли в будь-яке місце для вивантаження", "duplicates": "Дублікати", "duplicates_description": "Визначити, які групи є дублікатами", "duration": "Тривалість", @@ -1004,8 +1004,8 @@ "email": "Електронна пошта", "email_notifications": "Сповіщення ел. поштою", "empty_folder": "Ця папка порожня", - "empty_trash": "Очистити смітник", - "empty_trash_confirmation": "Ви впевнені, що хочете очистити смітник? Це остаточно видалить всі файли у смітнику з Immich.\nЦю дію не можна скасувати!", + "empty_trash": "Очистити кошик", + "empty_trash_confirmation": "Ви впевнені, що хочете очистити кошик? Це остаточно видалить всі файли у кошику з Immich.\nЦю дію не можна скасувати!", "enable": "Увімкнути", "enable_backup": "Увімкнути резервне копіювання", "enable_biometric_auth_description": "Введіть свій PIN-код, щоб увімкнути біометричну автентифікацію", @@ -1022,11 +1022,11 @@ "error_loading_albums": "Помилка завантаження альбомів", "error_loading_image": "Помилка завантаження зображення", "error_loading_partners": "Помилка завантаження партнерів: {error}", - "error_retrieving_asset_information": "Помилка отримання інформації про актив", + "error_retrieving_asset_information": "Помилка отримання інформації про файл", "error_saving_image": "Помилка: {error}", "error_tag_face_bounding_box": "Помилка під час позначення обличчя – не вдалося отримати координати рамки", "error_title": "Помилка: щось пішло не так", - "error_while_navigating": "Помилка під час переходу до ресурсу", + "error_while_navigating": "Помилка під час переходу до файлу", "errors": { "cannot_navigate_next_asset": "Не вдається перейти до наступного файлу", "cannot_navigate_previous_asset": "Не вдається перейти до попереднього файлу", @@ -1040,7 +1040,7 @@ "cant_search_places": "Не вдається виконати пошук місць", "error_adding_assets_to_album": "Помилка додавання файлів до альбому", "error_adding_users_to_album": "Помилка додавання користувачів до альбому", - "error_deleting_shared_user": "Помилка під час видалення користувача зі загальним доступом", + "error_deleting_shared_user": "Помилка під час видалення користувача зі спільним доступом", "error_downloading": "Помилка завантаження {filename}", "error_hiding_buy_button": "Помилка при спробі приховати кнопку покупки", "error_removing_assets_from_album": "Помилка видалення файлів з альбому, перевірте консоль для отримання додаткових відомостей", @@ -1098,7 +1098,7 @@ "unable_to_delete_workflow": "Не вдалося видалити робочий процес", "unable_to_download_files": "Неможливо завантажити файли", "unable_to_edit_exclusion_pattern": "Не вдалося редагувати шаблон виключення", - "unable_to_empty_trash": "Неможливо очистити смітник", + "unable_to_empty_trash": "Неможливо очистити кошик", "unable_to_enter_fullscreen": "Неможливо увійти в повноекранний режим", "unable_to_exit_fullscreen": "Неможливо вийти з повноекранного режиму", "unable_to_get_comments_number": "Не вдалося отримати кількість коментарів", @@ -1136,7 +1136,7 @@ "unable_to_set_feature_photo": "Не вдалося встановити фотографію на обкладинку", "unable_to_set_profile_picture": "Не вдається встановити зображення профілю", "unable_to_set_rating": "Не вдалося встановити рейтинг", - "unable_to_submit_job": "Не вдалося відправити завдання", + "unable_to_submit_job": "Не вдалося надіслати завдання", "unable_to_trash_asset": "Неможливо видалити файл", "unable_to_unlink_account": "Не вдається відв'язати обліковий запис", "unable_to_unlink_motion_video": "Не вдається від'єднати рухоме відео", @@ -1148,11 +1148,11 @@ "unable_to_update_timeline_display_status": "Не вдається оновити стан відображення шкали часу", "unable_to_update_user": "Неможливо оновити дані користувача", "unable_to_update_workflow": "Не вдалося оновити робочий процес", - "unable_to_upload_file": "Не вдалося завантажити файл" + "unable_to_upload_file": "Не вдалося вивантажити файл" }, "errors_text": "Помилки", "exclusion_pattern": "Шаблон виключення", - "exif": "Exif'", + "exif": "Exif", "exif_bottom_sheet_description": "Додати опис...", "exif_bottom_sheet_description_error": "Помилка під час оновлення опису", "exif_bottom_sheet_details": "Деталі", @@ -1186,16 +1186,15 @@ "failed_to_authenticate": "Помилка автентифікації", "failed_to_load_assets": "Не вдалося завантажити файли", "failed_to_load_folder": "Не вдалося завантажити папку", - "favorite": "До улюблених", + "favorite": "До обраного", "favorite_action_prompt": "{count} додано до обраного", "favorite_or_unfavorite_photo": "Додати до обраних або видалити з обраних фото", "favorites": "Обране", - "favorites_page_no_favorites": "Немає улюблених фото та відео", + "favorites_page_no_favorites": "Немає обраних фото та відео", "feature_photo_updated": "Вибране фото оновлено", "features": "Додаткові можливості", "features_in_development": "Функції в розробці", "features_setting_description": "Керування додатковими можливостями застосунку", - "file_name": "Ім'я файлу: {file_name}", "file_name_or_extension": "Ім'я файлу або розширення", "file_size": "Розмір файлу", "filename": "Ім'я файлу", @@ -1214,14 +1213,14 @@ "folders_feature_description": "Перегляд папок з фотографіями та відео у файловій системі", "forgot_pin_code_question": "Забули свій PIN-код?", "forward": "Переслати", - "free_up_space": "Звільніть місце", + "free_up_space": "Звільнити місце", "free_up_space_description": "Перемістіть резервні копії фотографій і відео до кошика вашого пристрою, щоб звільнити місце. Ваші копії на сервері залишаються в безпеці.", - "free_up_space_settings_subtitle": "Звільніть пам'ять пристрою", + "free_up_space_settings_subtitle": "Звільнити пам'ять пристрою", "full_path": "Повний шлях: {path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "Ця функція завантажує зовнішні ресурси з Google для своєї роботи.", "general": "Загальні", - "geolocation_instruction_location": "Натисніть на файл із геоданими, щоб використати його місцезнаходження, або виберіть місцезнаходження безпосередньо на карті", + "geolocation_instruction_location": "Натисніть на файл із геоданими, щоб використати його місцезнаходження, або виберіть місцезнаходження безпосередньо на мапі", "get_help": "Отримати допомогу", "get_people_error": "Помилка отримання людей", "get_wifiname_error": "Не вдалося отримати назву Wi-Fi. Переконайтеся, що ви надали необхідні дозволи та підключені до Wi-Fi мережі", @@ -1259,22 +1258,22 @@ "hide_schema": "Приховати схему", "hide_text_recognition": "Приховати розпізнавання тексту", "hide_unnamed_people": "Приховати людей без ім'я", - "home_page_add_to_album_conflicts": "Додано {added} файлів у альбом {album}. {failed} файлів вже було в альбомі.", + "home_page_add_to_album_conflicts": "Додано {added} файлів до альбому {album}. {failed} файлів вже було в альбомі.", "home_page_add_to_album_err_local": "Неможливо додати локальні файли до альбомів, пропускаю", - "home_page_add_to_album_success": "Додано {added} файлів у альбом {album}.", + "home_page_add_to_album_success": "Додано {added} файлів до альбому {album}.", "home_page_album_err_partner": "Поки що не вдається додати файли партнера до альбому, пропускаю", "home_page_archive_err_local": "Поки що неможливо заархівувати локальні файли, пропускаю", "home_page_archive_err_partner": "Неможливо архівувати файли партнера, пропускаю", "home_page_building_timeline": "Побудова хронології", "home_page_delete_err_partner": "Неможливо видалити файли партнера, пропускаю", "home_page_delete_remote_err_local": "Локальні файл(и) вже в процесі видалення з сервера, пропускаю", - "home_page_favorite_err_local": "Поки що не можна додати до улюблених локальні файли, пропускаю", - "home_page_favorite_err_partner": "Поки що не можна додати до улюблених файли партнера, пропускаю", + "home_page_favorite_err_local": "Поки що не можна додати до обраного локальні файли, пропускаю", + "home_page_favorite_err_partner": "Поки що не можна додати до обраного файли партнера, пропускаю", "home_page_first_time_notice": "Якщо ви користуєтеся застосунком вперше, будь ласка, оберіть альбом для резервного копіювання, щоб на шкалі часу з’явилися фото та відео", "home_page_locked_error_local": "Не вдається перемістити локальні файли до особистої папки, пропускаю", "home_page_locked_error_partner": "Не вдається перемістити партнерські файли до особистої папки, пропускаю", "home_page_share_err_local": "Неможливо поділитися локальними файлами через посилання, пропускаю", - "home_page_upload_err_limit": "Можна вантажити не більше 30 файлів водночас, пропускаю", + "home_page_upload_err_limit": "Можна вивантажувати не більше 30 файлів водночас, пропускаю", "host": "Хост", "hour": "Година", "hours": "Години", @@ -1298,16 +1297,16 @@ "image_viewer_page_state_provider_download_success": "Успішно завантажено", "image_viewer_page_state_provider_share_error": "Помилка спільного доступу", "immich_logo": "Логотип Immich", - "immich_web_interface": "Веб інтерфейс Immich", + "immich_web_interface": "Веб-інтерфейс Immich", "import_from_json": "Імпорт з JSON", "import_path": "Шлях імпорту", - "in_albums": "У {count, plural, one {# альбомі} other {# альбомах}}", + "in_albums": "У {count, plural, one {# альбомі} few {# альбомах} many {# альбомах} other {# альбомах}}", "in_archive": "В архіві", "in_year": "У {year}", "in_year_selector": "У", "include_archived": "Відображати архів", "include_shared_albums": "Включити спільні альбоми", - "include_shared_partner_assets": "Включайте спільні партнерські файли", + "include_shared_partner_assets": "Включити файли партнера", "individual_share": "Індивідуальний доступ", "individual_shares": "Окремі спільні доступи", "info": "Інформація", @@ -1325,7 +1324,7 @@ "ios_debug_info_last_sync_at": "Остання синхронізація {dateTime}", "ios_debug_info_no_processes_queued": "Фонові процеси відсутні в черзі", "ios_debug_info_no_sync_yet": "Фонове завдання синхронізації ще не запускалося", - "ios_debug_info_processes_queued": "{count, plural, one {{count} фоновий процес у черзі} other {{count} фонових процесів у черзі}}", + "ios_debug_info_processes_queued": "{count, plural, one {{count} фоновий процес у черзі} few {{count} фонові процеси у черзі} many {{count} фонових процесів у черзі} other {{count} фонових процесів у черзі}}", "ios_debug_info_processing_ran_at": "Обробку виконано {dateTime}", "items_count": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}}", "jobs": "Завдання", @@ -1333,7 +1332,7 @@ "json_error": "Помилка JSON", "keep": "Залишити", "keep_albums": "Зберігати альбоми", - "keep_albums_count": "Зберігання {count} {count, plural, one {альбом} other {альбоми}}", + "keep_albums_count": "Зберігання {count} {count, plural, one {альбом} few {альбоми} many {альбомів} other {альбомів}}", "keep_all": "Зберегти все", "keep_description": "Виберіть, що залишиться на вашому пристрої після звільнення місця.", "keep_favorites": "Зберегти обране", @@ -1350,7 +1349,7 @@ "language_setting_description": "Виберіть мову, якій ви надаєте перевагу", "large_files": "Великі файли", "last": "Останній", - "last_months": "{count, plural, one {Минулого місяця} other {Останні # місяці}}", + "last_months": "{count, plural, one {Минулого місяця} few {Останні # місяці} many {Останні # місяців} other {Останні # місяців}}", "last_seen": "Востаннє бачили", "latest_version": "Остання версія", "latitude": "Широта", @@ -1403,7 +1402,7 @@ "logged_out_all_devices": "Вийшли з усіх пристроїв", "logged_out_device": "Вихід з пристрою", "login": "Вхід", - "login_disabled": "Авторизація була відключена", + "login_disabled": "Авторизацію вимкнено", "login_form_api_exception": "Помилка API. Перевірте адресу сервера і спробуйте знову.", "login_form_back_button_text": "Назад", "login_form_email_hint": "youremail@email.com", @@ -1417,7 +1416,7 @@ "login_form_failed_get_oauth_server_config": "Помилка входу через OAuth, перевірте адресу сервера", "login_form_failed_get_oauth_server_disable": "OAuth недоступний на цьому сервері", "login_form_failed_login": "Помилка входу, перевірте URL-адресу сервера, електронну пошту та пароль", - "login_form_handshake_exception": "Виняток рукостискання з сервером. Увімкніть підтримку самопідписаного сертифіката в налаштуваннях, якщо ви використовуєте самопідписаний сертифікат.", + "login_form_handshake_exception": "Помилка встановлення з'єднання з сервером. Увімкніть підтримку самопідписаного сертифіката в налаштуваннях, якщо ви використовуєте самопідписаний сертифікат.", "login_form_password_hint": "пароль", "login_form_save_login": "Запам'ятати вхід", "login_form_server_empty": "Введіть URL-адресу сервера.", @@ -1459,19 +1458,19 @@ "maintenance_title": "Тимчасово недоступно", "make": "Виробник", "manage_geolocation": "Керувати місцезнаходженням", - "manage_media_access_rationale": "Цей дозвіл потрібен для належного переміщення файлів до смітника та їх відновлення з нього.", + "manage_media_access_rationale": "Цей дозвіл потрібен для належного переміщення файлів до кошика та їх відновлення з нього.", "manage_media_access_settings": "Відкрити налаштування", - "manage_media_access_subtitle": "Дозвольте програмі Immich керувати медіафайлами та переміщувати їх.", + "manage_media_access_subtitle": "Дозвольте застосунку Immich керувати медіафайлами та переміщувати їх.", "manage_media_access_title": "Доступ до керування медіа", "manage_shared_links": "Керування спільними посиланнями", "manage_sharing_with_partners": "Керування спільним доступом з партнерами", - "manage_the_app_settings": "Керування налаштуваннями програми", + "manage_the_app_settings": "Керування налаштуваннями застосунку", "manage_your_account": "Керування обліковим записом", "manage_your_api_keys": "Керування ключами API", "manage_your_devices": "Керування авторизованими пристроями", "manage_your_oauth_connection": "Налаштування підключеного OAuth", "map": "Мапа", - "map_assets_in_bounds": "{count, plural, =0 {Немає фотографій у цій місцевості} one {# фото} other {# фотографії}}", + "map_assets_in_bounds": "{count, plural, =0 {Немає фотографій у цій місцевості} one {# фото} few {# фотографії} many {# фотографій} other {# фотографій}}", "map_cannot_get_user_location": "Не можу отримати місцезнаходження", "map_location_dialog_yes": "Так", "map_location_picker_page_use_location": "Використати це місцезнаходження", @@ -1490,8 +1489,8 @@ "map_settings_dialog_title": "Налаштування мапи", "map_settings_include_show_archived": "Відображати архів", "map_settings_include_show_partners": "Відображати фото партнера", - "map_settings_only_show_favorites": "Лише улюбені", - "map_settings_theme_settings": "Тема карти", + "map_settings_only_show_favorites": "Лише обрані", + "map_settings_theme_settings": "Тема мапи", "map_zoom_to_see_photos": "Зменште масштаб, щоб побачити фото", "mark_all_as_read": "Позначити всі як прочитані", "mark_as_read": "Позначити як прочитане", @@ -1520,8 +1519,8 @@ "mirror_horizontal": "Горизонтальний", "mirror_vertical": "Вертикальний", "missing": "Відсутні", - "mobile_app": "Мобільний додаток", - "mobile_app_download_onboarding_note": "Завантажте супутній мобільний додаток, скориставшись наведеними нижче опціями", + "mobile_app": "Мобільний застосунок", + "mobile_app_download_onboarding_note": "Завантажте супутній мобільний застосунок, скориставшись наведеними нижче опціями", "model": "Модель", "month": "Місяць", "monthly_title_text_date_format": "ММММ р", @@ -1535,9 +1534,9 @@ "move_to_locked_folder": "Перемістити до особистої папки", "move_to_locked_folder_confirmation": "Ці фото та відео буде видалено зі всіх альбомів і їх можна буде переглядати лише в особистій папці", "move_up": "Перемістити вгору", - "moved_to_archive": "Переміщено {count, plural, one {# файл} other {# файлів}} в архів", - "moved_to_library": "Переміщено {count, plural, one {# файл} other {# файлів}} в бібліотеку", - "moved_to_trash": "Переміщено до смітника", + "moved_to_archive": "Переміщено {count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}} в архів", + "moved_to_library": "Переміщено {count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}} в бібліотеку", + "moved_to_trash": "Переміщено до кошика", "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату файлів лише для читання, пропускаю", "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати геолокацію файлів лише для читання, пропускаю", "mute_memories": "Приглушити спогади", @@ -1599,13 +1598,13 @@ "no_results": "Немає результатів", "no_results_description": "Спробуйте використовувати синонім або більш загальне ключове слово", "no_shared_albums_message": "Створіть альбом, щоб ділитися фотографіями та відео з людьми у вашій мережі", - "no_uploads_in_progress": "Немає активних завантажень", + "no_uploads_in_progress": "Немає активних вивантажень", "none": "Жоден", "not_allowed": "Не дозволено", "not_available": "Немає даних", "not_in_any_album": "У жодному альбомі", "not_selected": "Не вибрано", - "note_apply_storage_label_to_previously_uploaded assets": "Примітка: Щоб застосувати мітку сховища до раніше завантажених файлів, виконайте команду", + "note_apply_storage_label_to_previously_uploaded assets": "Примітка: Щоб застосувати мітку сховища до раніше вивантажених файлів, виконайте команду", "notes": "Нотатки", "nothing_here_yet": "Тут ще нічого немає", "notification_permission_dialog_content": "Щоб увімкнути сповіщення, перейдіть до Налаштувань і надайте дозвіл.", @@ -1617,7 +1616,7 @@ "notifications_setting_description": "Керування сповіщеннями", "oauth": "OAuth", "obtainium_configurator": "Конфігуратор Obtainium", - "obtainium_configurator_instructions": "Використовуйте Obtainium для встановлення та оновлення програми Android безпосередньо з релізу Immich на GitHub. Створіть ключ API та виберіть варіант, щоб створити посилання на конфігурацію Obtainium", + "obtainium_configurator_instructions": "Використовуйте Obtainium для встановлення та оновлення застосунку Android безпосередньо з релізу Immich на GitHub. Створіть ключ API та виберіть варіант, щоб створити посилання на конфігурацію Obtainium", "ocr": "OCR", "official_immich_resources": "Офіційні ресурси Immich", "offline": "Недоступний", @@ -1635,7 +1634,7 @@ "online": "Доступний", "only_favorites": "Лише обрані", "open": "Відкрити", - "open_in_map_view": "Відкрити у перегляді мапи", + "open_in_map_view": "Відкрити на мапі", "open_in_openstreetmap": "Відкрити в OpenStreetMap", "open_the_search_filters": "Відкрийте фільтри пошуку", "options": "Налаштування", @@ -1683,13 +1682,13 @@ "people": "Люди", "people_edits_count": "Відредаговано {count, plural, one {# особу} few {# особи} many {# осіб} other {# людей}}", "people_feature_description": "Перегляд фотографій і відео, згрупованих за людьми", - "people_selected": "{count, plural, one {# обрана особа} other {# вибрані люди}}", + "people_selected": "{count, plural, one {# обрана особа} few {# вибрані особи} many {# вибраних осіб} other {# вибраних осіб}}", "people_sidebar_description": "Відображення посилання на людей у бічній панелі", "permanent_deletion_warning": "Попередження про видалення", "permanent_deletion_warning_setting_description": "Показувати попередження при остаточному видаленні файлів", "permanently_delete": "Видалити назавжди", - "permanently_delete_assets_count": "Остаточно видалити {count, plural, one {файл} other {файли}}", - "permanently_delete_assets_prompt": "Ви впевнені, що хочете назавжди видалити {count, plural, one {цей файл?} other {ці # файли?}} Це також видалить {count, plural, one {його з його} other {їх з їхніх}} альбому(ів).", + "permanently_delete_assets_count": "Остаточно видалити {count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}}", + "permanently_delete_assets_prompt": "Ви впевнені, що хочете назавжди видалити {count, plural, one {цей файл?} few {ці # файли?} many {ці # файлів?} other {ці # файлів?}} Це також видалить {count, plural, one {його з} few {їх з} many {їх з} other {їх з}} альбому(ів).", "permanently_deleted_asset": "Файл видалено назавжди", "permanently_deleted_assets_count": "Видалено остаточно {count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}}", "permission": "Дозволи", @@ -1703,10 +1702,10 @@ "permission_onboarding_permission_limited": "Доступ обмежено. Щоби дозволити Immich створювати резервні копії та керувати всією галереєю, надайте дозволи на фото й відео в налаштуваннях.", "permission_onboarding_request": "Застосунку Immich потрібен дозвіл для перегляду ваших фото та відео.", "person": "Людина", - "person_age_months": "{months, plural, one {# місяць} other {# місяці}}", - "person_age_year_months": "1 рік, {months, plural, one {# місяць} other {# місяці}}", - "person_age_years": "{years, plural, other {# років}}", - "person_birthdate": "Народився {date}", + "person_age_months": "{months, plural, one {# місяць} few {# місяці} many {# місяців} other {# місяців}}", + "person_age_year_months": "1 рік, {months, plural, one {# місяць} few {# місяці} many {# місяців} other {# місяців}}", + "person_age_years": "{years, plural, one {# рік} few {# роки} many {# років} other {# років}}", + "person_birthdate": "Дата народження: {date}", "person_hidden": "{name}{hidden, select, true { (приховано)} other {}}", "person_recognized": "Особу розпізнали", "person_selected": "Обрана особа", @@ -1725,7 +1724,7 @@ "pin_verification": "Перевірка PIN-коду", "place": "Місце", "places": "Місця", - "places_count": "{count, plural, one {{count, number} Місце} other {{count, number} Місця}}", + "places_count": "{count, plural, one {{count, number} Місце} few {{count, number} Місця} many {{count, number} Місць} other {{count, number} Місць}}", "play": "Відтворити", "play_memories": "Відтворити спогади", "play_motion_photo": "Відтворювати рухомі фото", @@ -1796,15 +1795,15 @@ "rating_clear": "Очистити рейтинг", "rating_count": "{count, plural, one {# зірка} few {# зірки} many {# зірок} other {# зірок}}", "rating_description": "Показувати рейтинг EXIF на інформаційній панелі", - "rating_set": "Рейтинг встановлено на {rating, plural, one {# зірка} other {# зірки}}", - "reaction_options": "Опції реакції", + "rating_set": "Рейтинг встановлено на {rating, plural, one {# зірку} few {# зірки} many {# зірок} other {# зірок}}", + "reaction_options": "Параметри реакції", "read_changelog": "Прочитати зміни в оновленні", "readonly_mode_disabled": "Режим лише для читання вимкнено", "readonly_mode_enabled": "Режим лише для читання ввімкнено", - "ready_for_upload": "Готово до завантаження", + "ready_for_upload": "Готово до вивантаження", "reassign": "Перепризначити", "reassigned_assets_to_existing_person": "Перепризначено {count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}} {name, select, null {існуючій особі} other {{name}}}", - "reassigned_assets_to_new_person": "Перепризначено {count, plural, one {# файл} other {# файли}} новій особі", + "reassigned_assets_to_new_person": "Перепризначено {count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}} новій особі", "reassing_hint": "Призначити обрані файли існуючій особі", "recent": "Нещодавно", "recent-albums": "Останні альбоми", @@ -1823,7 +1822,7 @@ "refreshing_encoded_video": "Оновлення закодованого відео", "refreshing_faces": "Оновлення облич", "refreshing_metadata": "Оновлення метаданих", - "regenerating_thumbnails": "Відновлення мініатюр", + "regenerating_thumbnails": "Повторне створення мініатюр", "remote": "На сервері", "remote_assets": "Віддалені фото та відео", "remote_media_summary": "Зведення віддалених медіафайлів", @@ -1851,11 +1850,11 @@ "removed_from_favorites_count": "{count, plural, other {Видалено #}} з обраних", "removed_memory": "Видалений спогад", "removed_photo_from_memory": "Фото видалене зі спогаду", - "removed_tagged_assets": "Видалено тег із {count, plural, one {# файлу} other {# файлів}}", + "removed_tagged_assets": "Видалено тег із {count, plural, one {# файлу} few {# файлів} many {# файлів} other {# файлів}}", "rename": "Перейменувати", "repair": "Відновлення", "repair_no_results_message": "Невідстежувані та відсутні файли будуть відображені тут", - "replace_with_upload": "Замінити на завантажене", + "replace_with_upload": "Замінити на вивантажене", "repository": "Репозиторій", "require_password": "Вимагати пароль", "require_user_to_change_password_on_first_login": "Вимагати від користувача змінювати пароль при першому вході", @@ -1876,18 +1875,18 @@ "resolved_all_duplicates": "Усі дублікати усунуто", "restore": "Відновити", "restore_all": "Відновити все", - "restore_trash_action_prompt": "{count} відновлено зі смітника", + "restore_trash_action_prompt": "{count} відновлено з кошика", "restore_user": "Відновити користувача", "restored_asset": "Відновлений файл", "resume": "Продовжити", - "resume_paused_jobs": "Відновити {count, plural, one {# призупинене завдання} other {# призупинені завдання}}", - "retry_upload": "Повторити завантаження", + "resume_paused_jobs": "Відновити {count, plural, one {# призупинене завдання} few {# призупинені завдання} many {# призупинених завдань} other {# призупинених завдань}}", + "retry_upload": "Повторити вивантаження", "review_duplicates": "Переглянути дублікати", "review_large_files": "Перегляд великих файлів", "role": "Роль", "role_editor": "Редактор", "role_viewer": "Глядач", - "running": "Виконується", + "running": "Активний", "save": "Зберегти", "save_to_gallery": "Зберегти в галерею", "saved": "Збережено", @@ -1937,7 +1936,7 @@ "search_no_people": "Немає людей", "search_no_people_named": "Немає осіб з іменем \"{name}\"", "search_no_result": "Результатів не знайдено, спробуйте інший запит або комбінацію", - "search_options": "Опції пошуку", + "search_options": "Параметри пошуку", "search_page_categories": "Категорії", "search_page_motion_photos": "Живі фото", "search_page_no_objects": "Немає інформації про файли", @@ -1972,7 +1971,7 @@ "select_all_duplicates": "Вибрати всі дублікати", "select_all_in": "Вибрати все в {group}", "select_avatar_color": "Вибрати колір аватара", - "select_count": "{count, plural, one {Виберіть #} other {Вибрати #}}", + "select_count": "{count, plural, one {Вибрати #} few {Вибрати #} many {Вибрати #} other {Вибрати #}}", "select_cutoff_date": "Виберіть кінцеву дату", "select_face": "Виберіть обличчя", "select_featured_photo": "Обрати обране фото", @@ -1987,7 +1986,7 @@ "select_trash_all": "Видалити все вибране", "select_user_for_sharing_page_err_album": "Не вдалося створити альбом", "selected": "Обрано", - "selected_count": "{count, plural, one {# обраний} other {# обраних}}", + "selected_count": "{count, plural, one {# обраний} few {# обрані} many {# обраних} other {# обраних}}", "selected_gps_coordinates": "Вибрані координати", "send_message": "Надіслати повідомлення", "send_welcome_email": "Надішліть вітальний лист", @@ -2013,7 +2012,7 @@ "setting_image_viewer_help": "Повноекранний переглядач спочатку завантажує зображення для попереднього перегляду в низькій роздільній здатності, потім завантажує зображення в зменшеній роздільній здатності відносно оригіналу (якщо включено) і зрештою завантажує оригінал (якщо включено).", "setting_image_viewer_original_subtitle": "Увімкнути для завантаження оригінального зображення з повною роздільною здатністю (велике!). Вимкнути, щоб зменшити використання даних (як через мережу, так і на кеші пристрою).", "setting_image_viewer_original_title": "Завантажувати оригінальне зображення", - "setting_image_viewer_preview_subtitle": "Увімкнути для завантаження зображення середньої роздільної здатності. Вимкнути, щоб завантажувати оригінал або використовувати тільки ескіз.", + "setting_image_viewer_preview_subtitle": "Увімкнути для завантаження зображення середньої роздільної здатності. Вимкнути, щоб завантажувати оригінал або використовувати тільки мініатюру.", "setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду", "setting_image_viewer_title": "Зображення", "setting_languages_apply": "Застосувати", @@ -2035,7 +2034,7 @@ "setting_video_viewer_original_video_subtitle": "При трансляції відео з сервера відтворювати оригінал, навіть якщо доступна транскодування. Може призвести до буферизації. Відео, доступні локально, відтворюються в оригінальній якості, незважаючи на це налаштування.", "setting_video_viewer_original_video_title": "Примусово відтворювати оригінальне відео", "settings": "Налаштування", - "settings_require_restart": "Перезавантажте програму для застосування цього налаштування", + "settings_require_restart": "Перезавантажте застосунок для застосування цього налаштування", "settings_saved": "Налаштування збережені", "setup_pin_code": "Налаштувати PIN-код", "share": "Поширити", @@ -2056,7 +2055,7 @@ "shared_by_user": "Спільний доступ з {user}", "shared_by_you": "Ви поділились", "shared_from_partner": "Фото від {partner}", - "shared_intent_upload_button_progress_text": "{current} / {total} Завантажено", + "shared_intent_upload_button_progress_text": "{current} / {total} Вивантажено", "shared_link_app_bar_title": "Спільні посилання", "shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну", "shared_link_clipboard_text": "Посилання: {link}\nПароль: {password}", @@ -2086,7 +2085,7 @@ "shared_link_individual_shared": "Індивідуальний спільний доступ", "shared_link_info_chip_metadata": "EXIF", "shared_link_manage_links": "Керування спільними посиланнями", - "shared_link_options": "Опції спільних посилань", + "shared_link_options": "Параметри спільних посилань", "shared_link_password_description": "Вимагати пароль для доступу до цього спільного посилання", "shared_links": "Спільні посилання", "shared_links_description": "Діліться фото та відео за посиланням", @@ -2098,7 +2097,7 @@ "sharing_page_album": "Спільні альбоми", "sharing_page_description": "Створюйте спільні альбоми, щоб ділитися фото та відео з людьми зі своєї мережі.", "sharing_page_empty_list": "ПОРОЖНІЙ СПИСОК", - "sharing_sidebar_description": "Відображати посилання на загальний доступ у бічній панелі", + "sharing_sidebar_description": "Відображати посилання на спільний доступ у бічній панелі", "sharing_silver_appbar_create_shared_album": "Створити спільний альбом", "sharing_silver_appbar_share_partner": "Поділитися з партнером", "shift_to_permanent_delete": "натисніть ⇧ щоб видалити файл назавжди", @@ -2147,7 +2146,7 @@ "sort_people_by_similarity": "Сортувати людей за схожістю", "sort_recent": "Нещодавні", "sort_title": "Заголовок", - "source": "Вихідний код", + "source": "Джерело", "stack": "У стопку", "stack_action_prompt": "Згруповано: {count}", "stack_duplicates": "Групувати дублікати", @@ -2184,7 +2183,7 @@ "sync_remote": "Синхронізувати з сервером", "sync_status": "Стан синхронізації", "sync_status_subtitle": "Перегляд та керування системою синхронізації", - "sync_upload_album_setting_subtitle": "Створюйте та завантажуйте свої фотографії та відео до вибраних альбомів на сервер Immich", + "sync_upload_album_setting_subtitle": "Створюйте та вивантажуйте свої фотографії та відео до вибраних альбомів на сервер Immich", "tag": "Тег", "tag_assets": "Додати теги", "tag_created": "Створено тег: {tag}", @@ -2192,7 +2191,7 @@ "tag_not_found_question": "Не вдається знайти тег? Створити новий тег.", "tag_people": "Тег людей", "tag_updated": "Оновлено тег: {tag}", - "tagged_assets": "Позначено тегом {count, plural, one {# файл} other {# файли}}", + "tagged_assets": "Позначено тегом {count, plural, one {# файл} few {# файли} many {# файлів} other {# файлів}}", "tags": "Теги", "tap_to_run_job": "Торкніться, щоб запустити завдання", "template": "Шаблон", @@ -2225,32 +2224,32 @@ "to_change_password": "Змінити пароль", "to_favorite": "Обране", "to_login": "Вхід", - "to_multi_select": "для багаторазового вибору", + "to_multi_select": "для множинного вибору", "to_parent": "Повернутись назад", "to_select": "вибрати", - "to_trash": "Смітник", + "to_trash": "Кошик", "toggle_settings": "Перемикання налаштувань", "toggle_theme_description": "Перемкнути тему", "total": "Усього", "total_usage": "Загальне використання", - "trash": "Смітник", - "trash_action_prompt": "{count} переміщено до смітника", + "trash": "Кошик", + "trash_action_prompt": "{count} переміщено до кошика", "trash_all": "Видалити все", "trash_count": "Видалити {count, number}", - "trash_delete_asset": "У Смітник/Видалити файл", - "trash_emptied": "Смітник очищено", + "trash_delete_asset": "У Кошик/Видалити файл", + "trash_emptied": "Кошик очищено", "trash_no_results_message": "Тут з'являтимуться видалені фото та відео.", "trash_page_delete_all": "Видалити усе", - "trash_page_empty_trash_dialog_content": "Ви хочете очистити смітник? Ці файли будуть остаточно видалені з Immich", - "trash_page_info": "Переміщені до смітника файли буде остаточно видалено через {days} днів", + "trash_page_empty_trash_dialog_content": "Ви хочете очистити кошик? Ці файли будуть остаточно видалені з Immich", + "trash_page_info": "Переміщені до кошика файли буде остаточно видалено через {days} днів", "trash_page_no_assets": "Видалені фото та відео відсутні", "trash_page_restore_all": "Відновити усе", "trash_page_select_assets_btn": "Вибрати файли", - "trash_page_title": "Смітник ({count})", + "trash_page_title": "Кошик ({count})", "trashed_items_will_be_permanently_deleted_after": "Видалені файли будуть остаточно видалені через {days, plural, one {# день} few {# дні} many {# днів} other {# днів}}.", "trigger": "Тригер", "trigger_asset_uploaded": "Файл додано", - "trigger_asset_uploaded_description": "Запускається під час завантаження нового файлу", + "trigger_asset_uploaded_description": "Запускається під час вивантаження нового файлу", "trigger_description": "Подія, яка запускає автоматизацію", "trigger_person_recognized": "Особа розпізнана", "trigger_person_recognized_description": "Спрацьовує, коли виявляється людина", @@ -2258,13 +2257,13 @@ "troubleshoot": "Виправлення неполадок", "type": "Тип", "unable_to_change_pin_code": "Неможливо змінити PIN-код", - "unable_to_check_version": "Не вдається перевірити версію програми або сервера", + "unable_to_check_version": "Не вдається перевірити версію застосунку або сервера", "unable_to_setup_pin_code": "Неможливо налаштувати PIN-код", "unarchive": "Розархівувати", "unarchive_action_prompt": "{count, plural, one {# файл вилучено з архіву} few {# файли вилучено з архіву} other {# файлів вилучено з архіву}}", "unarchived_count": "{count, plural, other {Повернуто з архіву #}}", "undo": "Скасувати", - "unfavorite": "Видалити з улюблених", + "unfavorite": "Видалити з обраного", "unfavorite_action_prompt": "{count} вилучено з обраного", "unhide_person": "Розкрити особу", "unknown": "Невідомо", @@ -2293,23 +2292,23 @@ "update_location_action_prompt": "Оновити розташування вибраних об’єктів ({count}) за допомогою:", "updated_at": "Оновлено", "updated_password": "Пароль оновлено", - "upload": "Завантажити", - "upload_concurrency": "Паралельність завантаження", - "upload_details": "Деталі завантаження", + "upload": "Вивантажити", + "upload_concurrency": "Паралельність вивантаження", + "upload_details": "Деталі вивантаження", "upload_dialog_info": "Бажаєте створити резервну копію вибраних файлів на сервері?", - "upload_dialog_title": "Завантажити Файли", - "upload_error_with_count": "Помилка завантаження для {count, plural, one {# актив} other {# активи}}", - "upload_errors": "Завантаження завершено з {count, plural, one {# помилкою} few {# помилками} many {# помилками} other {# помилками}}, оновіть сторінку, щоб побачити нові завантажені файли.", - "upload_finished": "Завантаження завершено", + "upload_dialog_title": "Вивантажити файли", + "upload_error_with_count": "Помилка вивантаження для {count, plural, one {# файлу} few {# файлів} many {# файлів} other {# файлів}}", + "upload_errors": "Вивантаження завершено з {count, plural, one {# помилкою} few {# помилками} many {# помилками} other {# помилками}}, оновіть сторінку, щоб побачити нові вивантажені файли.", + "upload_finished": "Вивантаження завершено", "upload_progress": "Залишилось {remaining, number} - Опрацьовано {processed, number}/{total, number}", "upload_skipped_duplicates": "Пропущено {count, plural, one {# дубльований файл} few {# дубльовані файли} many {# дубльованих файлів} other {# дубльованих файлів}}", "upload_status_duplicates": "Дублікати", "upload_status_errors": "Помилки", - "upload_status_uploaded": "Завантажено", - "upload_success": "Завантаження успішне. Оновіть сторінку, щоб побачити нові завантажені файли.", - "upload_to_immich": "Завантажити в Immich ({count})", - "uploading": "Завантаження", - "uploading_media": "Виконується завантаження", + "upload_status_uploaded": "Вивантажено", + "upload_success": "Вивантаження успішне. Оновіть сторінку, щоб побачити нові вивантажені файли.", + "upload_to_immich": "Вивантажити в Immich ({count})", + "uploading": "Вивантаження", + "uploading_media": "Виконується вивантаження", "url": "URL", "usage": "Використання", "use_biometric": "Використовувати біометрію", diff --git a/i18n/vi.json b/i18n/vi.json index 7a94ab9482..ec1b497449 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -1140,7 +1140,6 @@ "features": "Tính năng", "features_in_development": "Tính năng đang được phát triển", "features_setting_description": "Quản lý các tính năng app", - "file_name": "Tên tệp: {file_name}", "file_name_or_extension": "Tên hoặc phần mở rộng tập tin", "file_size": "Kích cỡ tệp tin", "filename": "Tên tệp", diff --git a/i18n/yue_Hant.json b/i18n/yue_Hant.json index e88c5da1b0..823149da9e 100644 --- a/i18n/yue_Hant.json +++ b/i18n/yue_Hant.json @@ -2,6 +2,79 @@ "about": "關於", "account": "帳號", "account_settings": "帳號設定", + "acknowledge": "了解", "action": "動作", - "week": "星期" + "action_common_update": "更新", + "action_description": "針對篩選後嘅資源執行嘅", + "actions": "動作", + "active": "正在處理", + "active_count": "正在處理:{count}", + "activity": "活動", + "activity_changed": "活動已{enabled, select, true {啟動} other {停止}}", + "add": "加", + "add_a_description": "加一個描述", + "add_a_location": "加一個位置", + "add_a_name": "加一個姓名", + "add_a_title": "加一個標題", + "add_action": "加動作", + "add_action_description": "點擊以加動作", + "add_assets": "加資源", + "add_birthday": "加一個生日", + "add_endpoint": "加端點", + "add_filter": "加過濾器", + "add_filter_description": "點擊以加一個過濾條件", + "add_location": "加位置", + "add_more_users": "加更多用戶", + "add_partner": "加伙伴", + "add_path": "加路徑", + "add_photos": "加多張相片", + "add_tag": "加標籤", + "add_to": "加至…", + "add_to_album": "加至相簿", + "add_to_album_bottom_sheet_added": "已加至{album}", + "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", + "add_to_album_bottom_sheet_some_local_assets": "無法加部分本機資源至相簿", + "add_to_albums": "加至相簿", + "add_to_albums_count": "加 ({count}) 個項目至相簿", + "add_to_bottom_bar": "加至", + "add_to_shared_album": "加至共享相簿", + "add_url": "加網址", + "added_to_favorites": "已加至最愛", + "added_to_favorites_count": "已加{count, number} 個項目至最愛", + "admin": { + "admin_user": "管理員用戶", + "authentication_settings": "驗證設定", + "authentication_settings_description": "管理密碼、OAuth 同其他驗證設定", + "backup_onboarding_parts_title": "一個3-2-1備份包括:", + "backup_onboarding_title": "備份" + }, + "main_menu": "主選單", + "maintenance_action_restore": "還原緊數據庫", + "onboarding_user_welcome_description": "我哋而家開始喇!", + "onboarding_welcome_user": "歡迎,{user}", + "online": "已上線", + "only_favorites": "只顯示最愛", + "open": "開", + "open_in_map_view": "用地圖開", + "open_in_openstreetmap": "用 OpenStreetMap 開", + "open_the_search_filters": "開搜尋過濾器", + "options": "選項", + "or": "或者", + "organize_into_albums": "執成相簿", + "setting_notifications_notify_seconds": "{count} 秒", + "warning": "警告", + "week": "星期", + "welcome": "歡迎", + "welcome_to_immich": "歡迎使用 Immich", + "width": "寬", + "wifi_name": "Wi-Fi 名", + "wrong_pin_code": "PIN 碼唔啱", + "year": "年", + "years_ago": "{years, plural, one {#年} other {#年}}前", + "yes": "是", + "you_dont_have_any_shared_links": "你無共享連結", + "your_wifi_name": "你嘅 Wi-Fi 名稱", + "zero_to_clear_rating": "按0以清除資源評級", + "zoom_image": "縮放相片", + "zoom_to_bounds": "縮放至邊界" } diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index 85fd34a946..3742ca34f8 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -451,6 +451,9 @@ "admin_password": "管理員密碼", "administration": "管理", "advanced": "進階", + "advanced_settings_clear_image_cache": "清除圖片快取", + "advanced_settings_clear_image_cache_error": "清除圖片快取失敗", + "advanced_settings_clear_image_cache_success": "成功清除{size}", "advanced_settings_enable_alternate_media_filter_subtitle": "使用此選項可在同步時依其他條件篩選媒體。僅在應用程式無法偵測到所有相簿時再嘗試使用。", "advanced_settings_enable_alternate_media_filter_title": "[實驗性] 使用替代的裝置相簿同步篩選器", "advanced_settings_log_level_title": "日誌等級:{level}", @@ -514,6 +517,7 @@ "all": "全部", "all_albums": "所有相簿", "all_people": "所有人物", + "all_photos": "所有照片", "all_videos": "所有影片", "allow_dark_mode": "允許深色模式", "allow_edits": "允許編輯", @@ -521,6 +525,9 @@ "allow_public_user_to_upload": "允許公開使用者上傳", "allowed": "允許", "alt_text_qr_code": "QR code 圖片", + "always_keep": "一律保留", + "always_keep_photos_hint": "所有的照片將會被保留在此裝置上。", + "always_keep_videos_hint": "所有的影片將會被保留在此裝置上。", "anti_clockwise": "逆時針", "api_key": "API 金鑰", "api_key_description": "此金鑰僅顯示一次。請在關閉前複製它。", @@ -565,6 +572,9 @@ "asset_list_layout_sub_title": "版面", "asset_list_settings_subtitle": "相片格狀版面設定", "asset_list_settings_title": "相片格狀檢視", + "asset_not_found_on_device_android": "無法在裝置上找到項目", + "asset_not_found_on_device_ios": "無法在裝置上找到項目。iCloud 上的項目可能因檔案損失無法查閱", + "asset_not_found_on_icloud": "項目不存在於在iCloud。項目有機會因檔案損毀而無法檢閱", "asset_offline": "媒體離線", "asset_offline_description": "此外部媒體已無法在磁碟中找到。請聯絡您的 Immich 管理員以取得協助。", "asset_restored_successfully": "媒體復原成功", @@ -749,8 +759,18 @@ "checksum": "校驗和", "choose_matching_people_to_merge": "選擇要合併的相符人物", "city": "城市", - "cleanup_confirm_description": "Immich發現{count}個資產(在{date}之前創建)以安全備份到服務器。是否從此設備中刪除本地副本?", + "cleanup_confirm_description": "Immich 發現 {count} 個項目(在 {date} 之前創建)已安全備份到服務器。是否從此設備中刪除本地副本?", "cleanup_confirm_prompt_title": "從此裝置刪除?", + "cleanup_deleted_assets": "已將{count}項目移到裝置的垃圾桶裡", + "cleanup_deleting": "正在移動到垃圾桶...", + "cleanup_found_assets": "找到{count}件已上傳的項目", + "cleanup_found_assets_with_size": "找到{count}件,總共({size})已上傳的項目", + "cleanup_icloud_shared_albums_excluded": "iCloud共享相簿被排除於搜尋之外", + "cleanup_no_assets_found": "未找到任何符合條件的項目。釋放內存功能只能移除已備份到伺服器的項目", + "cleanup_preview_title": "{count} 項需要移除的項目", + "cleanup_step3_description": "掃描符合日期和保存設定的已備份項目。", + "cleanup_step4_summary": "從這台裝置上移除{count}件創建於{date}前的項目。照片仍然可以在Immich上查看。", + "cleanup_trash_hint": "要完全恢復內存,請清空相簿中的垃圾桶", "clear": "清空", "clear_all": "全部清除", "clear_all_recent_searches": "清除所有最近的搜尋", @@ -836,13 +856,20 @@ "created_at": "建立於", "creating_linked_albums": "建立連結相簿 ...", "crop": "裁剪", + "crop_aspect_ratio_fixed": "已修復", + "crop_aspect_ratio_free": "無限制", + "crop_aspect_ratio_original": "原檔", "curated_object_page_title": "事物", "current_device": "目前裝置", "current_pin_code": "目前 PIN 碼", "current_server_address": "目前的伺服器位址", + "custom_date": "另選日期", "custom_locale": "自訂地區設定", "custom_locale_description": "根據語言與地區格式化日期與數字", "custom_url": "自訂 URL", + "cutoff_date_description": "保留最近多少天的照片…", + "cutoff_day": "{count, plural, one {天} other {天}}", + "cutoff_year": "{count, plural, one {年} other {年}}", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "YYYY 年 M 月 D 日 (E)", "dark": "深色", @@ -924,6 +951,7 @@ "download_include_embedded_motion_videos": "嵌入影片", "download_include_embedded_motion_videos_description": "將動態相片中內嵌的影片另存為獨立檔案", "download_notfound": "無法找到下載", + "download_original": "下載原始文件", "download_paused": "下載已暫停", "download_settings": "下載", "download_settings_description": "管理與媒體下載相關的設定", @@ -933,6 +961,7 @@ "download_waiting_to_retry": "等待重試", "downloading": "下載中", "downloading_asset_filename": "正在下載媒體 {filename}", + "downloading_from_icloud": "正從iCloud下載", "downloading_media": "正在下載媒體", "drop_files_to_upload": "將檔案拖放到任何位置以上傳", "duplicates": "重複項目", @@ -965,6 +994,13 @@ "editor": "編輯器", "editor_close_without_save_prompt": "此變更將不會被儲存", "editor_close_without_save_title": "要關閉編輯器嗎?", + "editor_confirm_reset_all_changes": "你確定要重設所有變更嗎?", + "editor_flip_horizontal": "水平翻轉", + "editor_flip_vertical": "垂直翻轉", + "editor_orientation": "方向", + "editor_reset_all_changes": "重設變更", + "editor_rotate_left": "逆時針旋轉90度", + "editor_rotate_right": "順時針旋轉90度", "email": "電子郵件", "email_notifications": "Email 通知", "empty_folder": "這個資料夾是空的", @@ -983,11 +1019,14 @@ "error_change_sort_album": "變更相簿排序失敗", "error_delete_face": "從媒體刪除臉孔時失敗", "error_getting_places": "取得位置時出錯", + "error_loading_albums": "無法加載相簿", "error_loading_image": "圖片載入錯誤", "error_loading_partners": "載入合作夥伴時出錯:{error}", + "error_retrieving_asset_information": "無法獲取項目資訊", "error_saving_image": "錯誤:{error}", "error_tag_face_bounding_box": "標記臉部錯誤 - 無法取得邊界框坐標", "error_title": "錯誤 - 發生錯誤", + "error_while_navigating": "無法引導至項目", "errors": { "cannot_navigate_next_asset": "無法導覽至下一個媒體", "cannot_navigate_previous_asset": "無法導覽至上一個媒體", @@ -1111,6 +1150,7 @@ "unable_to_update_workflow": "無法更新工作流", "unable_to_upload_file": "無法上傳檔案" }, + "errors_text": "錯誤", "exclusion_pattern": "排除模式", "exif": "EXIF 可交換影像檔格式", "exif_bottom_sheet_description": "新增描述...", @@ -1155,7 +1195,6 @@ "features": "功能", "features_in_development": "發展中的特點", "features_setting_description": "管理應用程式功能", - "file_name": "檔案名稱:{file_name}", "file_name_or_extension": "檔案名稱或副檔名", "file_size": "文件大小", "filename": "檔案名稱", @@ -1174,6 +1213,9 @@ "folders_feature_description": "透過資料夾檢視瀏覽檔案系統中的相片與影片", "forgot_pin_code_question": "忘記您的 PIN 碼?", "forward": "由新至舊", + "free_up_space": "釋放內存", + "free_up_space_description": "已備份照片和影片已經移到裝置的垃圾桶以釋放內存。伺服器上的存檔依然安全。", + "free_up_space_settings_subtitle": "釋放裝置內存", "full_path": "完整路徑:{path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "此功能需要從 Google 載入外部資源才能正常運作。", @@ -1289,8 +1331,14 @@ "json_editor": "JSON編輯器", "json_error": "JSON錯誤", "keep": "保留", + "keep_albums": "保留相簿", "keep_all": "全部保留", + "keep_description": "選擇釋放空間時,保留在裝置上的相片", + "keep_favorites": "保留最愛的相片", + "keep_on_device": "保留在裝置上", + "keep_on_device_hint": "選擇保留在裝置上的相片", "keep_this_delete_others": "保留這個,刪除其他", + "keeping": "保留:{items}", "kept_this_deleted_others": "保留這個項目並刪除{count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "鍵盤快捷鍵", "language": "語言", @@ -1384,10 +1432,25 @@ "loop_videos_description": "啟用後,影片結束會自動重播。", "main_branch_warning": "您現在使用的是開發版本;我們強烈您建議使用正式發行版!", "main_menu": "主選單", + "maintenance_action_restore": "復原資料庫", "maintenance_description": "Immich已進入維護模式。", "maintenance_end": "結束維護模式", "maintenance_end_error": "未能結束維護模式。", "maintenance_logged_in_as": "當前以{user}身份登入", + "maintenance_restore_from_backup": "從備份復原", + "maintenance_restore_library": "復原你的相簿", + "maintenance_restore_library_confirm": "確認是否正確,將繼續從備份復原!", + "maintenance_restore_library_description": "正在復原資料庫", + "maintenance_restore_library_folder_has_files": "{folder}有{count}個資料夾", + "maintenance_restore_library_folder_no_files": "{folder}有缺失的檔案!", + "maintenance_restore_library_folder_pass": "可以讀寫", + "maintenance_restore_library_folder_read_fail": "無法讀取", + "maintenance_restore_library_folder_write_fail": "無法寫入", + "maintenance_restore_library_hint_missing_files": "可能遺失重要檔案", + "maintenance_restore_library_hint_regenerate_later": "之後可以在設定重新產生", + "maintenance_task_backup": "正在建立現有資料庫的備份…", + "maintenance_task_restore": "正在從選擇的備份復原…", + "maintenance_task_rollback": "復原失敗,恢復到之前的儲存…", "maintenance_title": "暫時不可用", "make": "製造商", "manage_geolocation": "管理位置", @@ -1449,6 +1512,8 @@ "minimize": "最小化", "minute": "分", "minutes": "分鐘", + "mirror_horizontal": "水平", + "mirror_vertical": "垂直", "missing": "排入未處理", "mobile_app": "移動應用程序", "mobile_app_download_onboarding_note": "使用以下選項下載配套移動應用程序", @@ -1460,6 +1525,7 @@ "move_down": "向下移動", "move_off_locked_folder": "移出鎖定的資料夾", "move_to": "移動到", + "move_to_device_trash": "移動到裝置的垃圾桶", "move_to_lock_folder_action_prompt": "{count} 已新增至鎖定的資料夾中", "move_to_locked_folder": "移至鎖定的資料夾", "move_to_locked_folder_confirmation": "這些照片和影片將從所有相簿中移除,並僅可從鎖定的資料夾檢視", @@ -1499,6 +1565,7 @@ "next_memory": "下一張回憶", "no": "否", "no_actions_added": "尚未添加任何操作", + "no_albums_found": "無相簿", "no_albums_message": "建立相簿來整理照片和影片", "no_albums_with_name_yet": "看來還沒有這個名字的相簿。", "no_albums_yet": "看來您還沒有任何相簿。", @@ -1528,6 +1595,7 @@ "no_results_description": "試試同義詞或更通用的關鍵字吧", "no_shared_albums_message": "建立相簿分享照片和影片", "no_uploads_in_progress": "沒有正在上傳的項目", + "none": "無", "not_allowed": "不允許", "not_available": "不適用", "not_in_any_album": "不在任何相簿中", @@ -1630,7 +1698,7 @@ "permission_onboarding_permission_limited": "如要繼續,請允許 Immich 備份和管理您的相簿收藏,在設定中授予相片和影片權限。", "permission_onboarding_request": "Immich 需要權限才能檢視您的相片和短片。", "person": "人物", - "person_age_months": "{months, plural, one {# 個月} other {# 個月}}前", + "person_age_months": "{months, plural, one {# 個月} other {# 個月}}", "person_age_year_months": "1 年 {months, plural, one {# 個月} other {# 個月}}", "person_age_years": "{years, plural, other {# 歲}}", "person_birthdate": "生於 {date}", @@ -1642,6 +1710,7 @@ "photos_and_videos": "照片及影片", "photos_count": "{count, plural, other {{count, number} 張照片}}", "photos_from_previous_years": "往年的照片", + "photos_only": "只允許照片", "pick_a_location": "選擇位置", "pick_custom_range": "自定義範圍", "pick_date_range": "選擇日期範圍", @@ -1822,9 +1891,11 @@ "saved_settings": "已儲存設定", "say_something": "說說您的想法吧", "scaffold_body_error_occurred": "發生錯誤", + "scan": "掃描", "scan_all_libraries": "掃描所有相簿", "scan_library": "掃描", "scan_settings": "掃描設定", + "scanning": "正在掃描", "scanning_for_album": "掃描相簿中……", "search": "搜尋", "search_albums": "搜尋相簿", @@ -2188,6 +2259,7 @@ "unhide_person": "取消隱藏人物", "unknown": "未知", "unknown_country": "未知國家", + "unknown_date": "未知的日期", "unknown_year": "未知年份", "unlimited": "不限制", "unlink_motion_video": "解除連結動態影片", @@ -2263,6 +2335,7 @@ "video_hover_setting_description": "當滑鼠停在項目上時播放影片縮圖。即使停用,將滑鼠停在播放圖示上也可以播放。", "videos": "影片", "videos_count": "{count, plural, other {# 部影片}}", + "videos_only": "只允許影片", "view": "檢視", "view_album": "檢視相簿", "view_all": "瀏覽全部", diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json index d76191e604..23f3ea4eec 100644 --- a/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_SIMPLIFIED.json @@ -307,7 +307,7 @@ "require_password_change_on_login": "强制用户首次登录时修改密码", "reset_settings_to_default": "将设置重置为默认值", "reset_settings_to_recent_saved": "将设置重置为上次保存的值", - "scanning_library": "正在扫描资产库", + "scanning_library": "正在扫描资料库", "search_jobs": "搜索任务…", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", @@ -540,136 +540,139 @@ "app_download_links": "APP下载链接", "app_settings": "应用设置", "app_stores": "应用商店", - "app_update_available": "应用程序更新可用", - "appears_in": "所属相册", - "apply_count": "应用 ({count, number}个资产)", + "app_update_available": "应用更新已发布", + "appears_in": "收录于", + "apply_count": "应用 ({count, number})", "archive": "归档", "archive_action_prompt": "已将 {count} 项添加到归档", "archive_or_unarchive_photo": "归档或取消归档照片", - "archive_page_no_archived_assets": "未找到归档资产", + "archive_page_no_archived_assets": "未找到已归的资源", "archive_page_title": "归档({count})", "archive_size": "归档大小", - "archive_size_description": "配置下载归档大小(GiB)", + "archive_size_description": "配置下载的归档大小(GiB)", "archived": "已归档", "archived_count": "{count, plural, other {已归档 # 项}}", - "are_these_the_same_person": "他们是同一个人吗?", - "are_you_sure_to_do_this": "确定执行此操作?", - "array_field_not_fully_supported": "数组字段需要手动编辑 JSON", - "asset_action_delete_err_read_only": "无法删除只读资产,跳过", - "asset_action_share_err_offline": "无法获取离线资产,跳过", + "are_these_the_same_person": "这是同一个人吗?", + "are_you_sure_to_do_this": "确定要执行此操作?", + "array_field_not_fully_supported": "数组字段需要手动进行 JSON 编辑", + "asset_action_delete_err_read_only": "无法删除只读资源,已跳过", + "asset_action_share_err_offline": "无法获取离线资源,已跳过", "asset_added_to_album": "已添加至相册", "asset_adding_to_album": "正在添加至相册…", - "asset_created": "已创建资产", - "asset_description_updated": "资产描述已更新", - "asset_filename_is_offline": "资产“{filename}”已离线", - "asset_has_unassigned_faces": "资产中有未分配的人脸", - "asset_hashing": "哈希校验中…", - "asset_list_group_by_sub_title": "分组方式", + "asset_created": "资源已创建", + "asset_description_updated": "资源描述已更新", + "asset_filename_is_offline": "资源“{filename}”已离线", + "asset_has_unassigned_faces": "资源包含未分配的人脸", + "asset_hashing": "正在计算哈希值…", + "asset_list_group_by_sub_title": "分组依据", "asset_list_layout_settings_dynamic_layout_title": "动态布局", "asset_list_layout_settings_group_automatically": "自动", - "asset_list_layout_settings_group_by": "资产分组方式", - "asset_list_layout_settings_group_by_month_day": "月和日", + "asset_list_layout_settings_group_by": "资源分组依据", + "asset_list_layout_settings_group_by_month_day": "月份 + 日期", "asset_list_layout_sub_title": "布局", "asset_list_settings_subtitle": "照片网格布局设置", "asset_list_settings_title": "照片网格", - "asset_offline": "资产脱机", - "asset_offline_description": "磁盘上已找不到该外部资产。请联系您的 Immich 管理员寻求帮助。", - "asset_restored_successfully": "已成功恢复所有资产", + "asset_not_found_on_device_android": "设备上未找到该资源", + "asset_not_found_on_device_ios": "设备上未找到该资源。如果您使用了 iCloud,可能是由于 iCloud 中存储了错误的文件导致资源无法访问", + "asset_not_found_on_icloud": "iCloud 中未找到该资源。可能是由于 iCloud 中存储了错误的文件导致资源无法访问", + "asset_offline": "资源离线", + "asset_offline_description": "磁盘上未找到此外部资源。请联系您的 Immich 管理员寻求帮助。", + "asset_restored_successfully": "资源恢复成功", "asset_skipped": "已跳过", - "asset_skipped_in_trash": "已回收", - "asset_trashed": "资产已被删除", - "asset_troubleshoot": "资产故障排除", + "asset_skipped_in_trash": "在回收站中", + "asset_trashed": "资源已移至回收站", + "asset_troubleshoot": "资源诊断", "asset_uploaded": "已上传", "asset_uploading": "上传中…", - "asset_viewer_settings_subtitle": "管理图库浏览器设置", + "asset_viewer_settings_subtitle": "管理画廊查看器设置", "asset_viewer_settings_title": "资源查看器", - "assets": "资产", - "assets_added_count": "已添加{count, plural, one {#个资产} other {#个资产}}", - "assets_added_to_album_count": "已添加{count, plural, one {#个资产} other {#个资产}}到相册", - "assets_added_to_albums_count": "已添加 {assetTotal, plural, one {# 个资产} other {# 个资产}}到 {albumTotal, plural, one {# 个相册} other {# 个相册}}", - "assets_cannot_be_added_to_album_count": "{count, plural, one {个资产} other {个资产}} 无法添加到相册中", - "assets_cannot_be_added_to_albums": "{count, plural, one {个资产} other {个资产}} 无法添加到相册", - "assets_count": "{count, plural, one {#个资产} other {#个资产}}", - "assets_deleted_permanently": "{count} 个资产已被永久删除", + "assets": "资源", + "assets_added_count": "已添加{count, plural, one {#个资源} other {#个资源}}", + "assets_added_to_album_count": "已向相册添加{count, plural, one {#个资源} other {#个资源}}", + "assets_added_to_albums_count": "已向 {albumTotal, plural, one {# 个相册} other {# 个相册}}添加 {assetTotal, plural, one {# 个资源} other {# 个资源}}", + "assets_cannot_be_added_to_album_count": "无法向相册添加{count, plural, one {个资源} other {个资源}}", + "assets_cannot_be_added_to_albums": "无法向任何一个相册添加 {count, plural, one {个资源} other {个资源}}", + "assets_count": "{count, plural, one {#个资源} other {#个资源}}", + "assets_deleted_permanently": "已永久删除 {count} 个资源", "assets_deleted_permanently_from_server": "已永久移除 {count} 个资产", - "assets_downloaded_failed": "{count, plural, one {已下载#个文件 - {error} 文件失败} other {已下载#个文件 - {error} 个文件失败}}", - "assets_downloaded_successfully": "{count, plural, one {已成功下载了 # 个文件} other {已成功下载了 # 个文件}}", - "assets_moved_to_trash_count": "{count, plural, one {#个资产} other {#个资产}}已移动到回收站", - "assets_permanently_deleted_count": "已永久删除{count, plural, one {#个资产} other {#个资产}}", - "assets_removed_count": "已移除{count, plural, one {#个资产} other {#个资产}}", - "assets_removed_permanently_from_device": "已从设备中永久移除 {count} 个资产", - "assets_restore_confirmation": "确定要恢复回收站中的所有资产吗?该操作无法撤消!请注意,脱机项目无法通过这种方式恢复。", - "assets_restored_count": "已恢复{count, plural, one {#个资产} other {#个资产}}", - "assets_restored_successfully": "已成功恢复{count}个资产", - "assets_trashed": "{count} 个资产放入回收站", - "assets_trashed_count": "{count, plural, one {#个资产} other {#个资产}}已放入回收站", - "assets_trashed_from_server": "{count} 个资产已放入回收站", - "assets_were_part_of_album_count": "{count, plural, one {个资产} other {个资产}}已经在相册中", - "assets_were_part_of_albums_count": "{count, plural, one {个资产} other {个资产}} 已在相册中", + "assets_downloaded_failed": "{count, plural, one {已下载#个文件 - {error} 个文件下载失败} other {已下载#个文件 - {error} 个文件下载失败}}", + "assets_downloaded_successfully": "{count, plural, one {已成功下载 # 个文件} other {已成功下载 # 个文件}}", + "assets_moved_to_trash_count": "已将{count, plural, one {#个资源} other {#个资源}}移动到回收站", + "assets_permanently_deleted_count": "已永久删除{count, plural, one {#个资源} other {#个资源}}", + "assets_removed_count": "已移除{count, plural, one {#个资源} other {#个资源}}", + "assets_removed_permanently_from_device": "已从您的设备中永久删除 {count} 个资源", + "assets_restore_confirmation": "您确定要恢复回收站中的所有资源吗?此操作无法撤销!请注意,任何离线资源无法通过此方式恢复。", + "assets_restored_count": "已恢复{count, plural, one {#个资源} other {#个资源}}", + "assets_restored_successfully": "已成功恢复{count}个资源", + "assets_trashed": "{count} 个资源移至回收站", + "assets_trashed_count": "已将{count, plural, one {#个资源} other {#个资源}}移至回收站", + "assets_trashed_from_server": "Immich 服务器上已移除 {count} 个资源", + "assets_were_part_of_album_count": "{count, plural, one {个资源} other {个资源}}已在该相册中", + "assets_were_part_of_albums_count": "{count, plural, one {个资源} other {个资源}} 已存在于这些相册中", "authorized_devices": "已授权设备", - "automatic_endpoint_switching_subtitle": "连接指定 Wi-Fi 时使用本地网络,否则使用外部网络", + "automatic_endpoint_switching_subtitle": "在可用时通过指定的 Wi-Fi 进行本地连接,其他位置则使用替代网络连接", "automatic_endpoint_switching_title": "自动切换 URL", "autoplay_slideshow": "自动播放幻灯片", "back": "返回", "back_close_deselect": "返回、关闭或取消选择", - "background_backup_running_error": "后台备份正在运行,无法启动手动备份", + "background_backup_running_error": "后台备份正在运行中,无法启动手动备份", "background_location_permission": "后台定位权限", - "background_location_permission_content": "为确保后台运行时自动切换网络,需授予 Immich *始终允许精确定位* 权限,以识别 Wi-Fi 网络名称", - "background_options": "背景选项", + "background_location_permission_content": "为了在后台运行时实现网络切换,Immich 必须始终拥有精确位置访问权限,以便应用能够读取 Wi-Fi 网络的名称", + "background_options": "后台选项", "backup": "备份", "backup_album_selection_page_albums_device": "设备上的相册({count})", - "backup_album_selection_page_albums_tap": "单击选中,双击取消", - "backup_album_selection_page_assets_scatter": "资产可能分散在多个相册中。因此,在备份过程中可以选择包含或排除特定相册。", + "backup_album_selection_page_albums_tap": "单击包含,双击排除", + "backup_album_selection_page_assets_scatter": "资源文件可能分散在多个相册中。因此,在备份过程中,您可以选择包含或排除特定的相册。", "backup_album_selection_page_select_albums": "选择相册", "backup_album_selection_page_selection_info": "选择信息", - "backup_album_selection_page_total_assets": "总计", + "backup_album_selection_page_total_assets": "唯一资源总计", "backup_albums_sync": "备份相册同步", "backup_all": "全部", - "backup_background_service_backup_failed_message": "备份失败,正在重试…", - "backup_background_service_complete_notification": "资产备份完成", - "backup_background_service_connection_failed_message": "连接服务器失败,正在重试…", + "backup_background_service_backup_failed_message": "资源备份失败。正在重试…", + "backup_background_service_complete_notification": "资源备份完成", + "backup_background_service_connection_failed_message": "无法连接到服务器。正在重试…", "backup_background_service_current_upload_notification": "正在上传 “{filename}”", - "backup_background_service_default_notification": "正在检查新资产…", - "backup_background_service_error_title": "备份失败", - "backup_background_service_in_progress_notification": "正在备份您的资产…", + "backup_background_service_default_notification": "正在检查新资源…", + "backup_background_service_error_title": "备份错误", + "backup_background_service_in_progress_notification": "正在备份您的资源…", "backup_background_service_upload_failure_notification": "“{filename}”上传失败", "backup_controller_page_albums": "备份相册", - "backup_controller_page_background_app_refresh_disabled_content": "要使用后台备份功能,请在“设置”>“常规”>“后台应用刷新”中启用后台应用程序刷新。", - "backup_controller_page_background_app_refresh_disabled_title": "后台应用刷新已禁用", + "backup_controller_page_background_app_refresh_disabled_content": "在“设置”>“通用”>“后台 App 刷新”中启用此功能,以使用后台备份。", + "backup_controller_page_background_app_refresh_disabled_title": "后台 App 刷新已关闭", "backup_controller_page_background_app_refresh_enable_button_text": "前往设置", - "backup_controller_page_background_battery_info_link": "怎么做", - "backup_controller_page_background_battery_info_message": "为了获得最佳的后台备份体验,请禁用任何限制 Immich 后台活动的电池优化。\n\n由于这是设备相关的,因此请查找设备制造商提供的信息进行操作。", + "backup_controller_page_background_battery_info_link": "展示操作步骤", + "backup_controller_page_background_battery_info_message": "为获得最佳的后台备份体验,请在系统设置中禁用针对 Immich 的任何电池优化限制。\n\n由于该设置因设备而异,请查询您设备制造商的具体要求。", "backup_controller_page_background_battery_info_ok": "我知道了", "backup_controller_page_background_battery_info_title": "电池优化", - "backup_controller_page_background_charging": "仅充电时", - "backup_controller_page_background_configure_error": "配置后台服务失败", - "backup_controller_page_background_delay": "延迟备份的新资产:{duration}", - "backup_controller_page_background_description": "打开后台服务以自动备份任何新资产,且无需打开应用", - "backup_controller_page_background_is_off": "后台自动备份已关闭", + "backup_controller_page_background_charging": "仅在充电时", + "backup_controller_page_background_configure_error": "后台服务配置失败", + "backup_controller_page_background_delay": "延迟新文件备份:{duration}", + "backup_controller_page_background_description": "开启后台服务,即可在无需打开 App 的情况下,自动备份所有新文件", + "backup_controller_page_background_is_off": "后台自动备份未开启", "backup_controller_page_background_is_on": "后台自动备份已开启", "backup_controller_page_background_turn_off": "关闭后台服务", "backup_controller_page_background_turn_on": "开启后台服务", - "backup_controller_page_background_wifi": "仅 Wi-Fi", + "backup_controller_page_background_wifi": "仅在 Wi-Fi 下", "backup_controller_page_backup": "备份", - "backup_controller_page_backup_selected": "已选中: ", + "backup_controller_page_backup_selected": "已选: ", "backup_controller_page_backup_sub": "已备份的照片和视频", "backup_controller_page_created": "创建时间:{date}", - "backup_controller_page_desc_backup": "打开前台备份,以在程序运行时自动备份新资产。", + "backup_controller_page_desc_backup": "开启前台备份,打开 App 即自动上传新文件。", "backup_controller_page_excluded": "已排除: ", "backup_controller_page_failed": "失败({count})", - "backup_controller_page_filename": "文件名称:{filename} [{size}]", + "backup_controller_page_filename": "文件名:{filename} [{size}]", "backup_controller_page_id": "ID:{id}", "backup_controller_page_info": "备份信息", - "backup_controller_page_none_selected": "未选择", + "backup_controller_page_none_selected": "暂未选择", "backup_controller_page_remainder": "剩余", - "backup_controller_page_remainder_sub": "所选数据中尚未备份的数据", + "backup_controller_page_remainder_sub": "已选项中尚未备份的照片和视频", "backup_controller_page_server_storage": "服务器存储", "backup_controller_page_start_backup": "开始备份", - "backup_controller_page_status_off": "前台自动备份已关闭", - "backup_controller_page_status_on": "前台自动备份已开启", - "backup_controller_page_storage_format": "{used}/{total} 已使用", - "backup_controller_page_to_backup": "要备份的相册", - "backup_controller_page_total_sub": "选中相册中所有不重复的视频和图像", + "backup_controller_page_status_off": "未开启前台自动备份", + "backup_controller_page_status_on": "前台自动备份已打开", + "backup_controller_page_storage_format": "已用 {used}(共 {total})", + "backup_controller_page_to_backup": "待备份的相册", + "backup_controller_page_total_sub": "包含所选相册内全部唯一的照片和视频", "backup_controller_page_turn_off": "关闭前台备份", "backup_controller_page_turn_on": "开启前台备份", "backup_controller_page_uploading_file_info": "正在上传中的文件信息", @@ -1192,7 +1195,6 @@ "features": "功能", "features_in_development": "开发中的功能", "features_setting_description": "管理 App 功能", - "file_name": "文件名:{file_name}", "file_name_or_extension": "文件名或扩展名", "file_size": "大小", "filename": "文件名", @@ -1240,7 +1242,7 @@ "has_quota": "配额大小", "hash_asset": "哈希项目", "hashed_assets": "已哈希的项目", - "hashing": "正在哈希", + "hashing": "正在进行哈希检验", "header_settings_add_header_tip": "添加标头", "header_settings_field_validator_msg": "设置不可为空", "header_settings_header_name_input": "标头名称", @@ -1517,7 +1519,7 @@ "mirror_horizontal": "水平", "mirror_vertical": "垂直", "missing": "缺失", - "mobile_app": "手机APP", + "mobile_app": "移动端APP", "mobile_app_download_onboarding_note": "下载移动应用以访问这些选项", "model": "型号", "month": "月", @@ -1818,9 +1820,9 @@ "refreshed": "已刷新", "refreshes_every_file": "重新扫描所有现有文件和新文件", "refreshing_encoded_video": "正在刷新已编码视频", - "refreshing_faces": "正在面部重新识别", - "refreshing_metadata": "正在刷新元数据", - "regenerating_thumbnails": "正在重新生成缩略图", + "refreshing_faces": "刷新面部识别", + "refreshing_metadata": "刷新元数据", + "regenerating_thumbnails": "重新生成缩略图", "remote": "远程", "remote_assets": "远程项目", "remote_media_summary": "远程媒体摘要", @@ -2295,6 +2297,7 @@ "upload_details": "上传详情", "upload_dialog_info": "是否要将所选项目备份到服务器?", "upload_dialog_title": "上传项目", + "upload_error_with_count": "{count, plural, one {# 个项目} other {# 个项目}}上传错误", "upload_errors": "上传完成,出现{count, plural, one {#个错误} other {#个错误}},刷新页面以查看新上传的项目。", "upload_finished": "上传完成", "upload_progress": "剩余{remaining, number} - 已处理 {processed, number}/{total, number}", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 87ead41378..ed34c6a338 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "immich-ml" -version = "2.5.2" +version = "2.5.5" description = "" authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] requires-python = ">=3.11,<4.0" @@ -41,7 +41,6 @@ types = [ "types-ujson>=5.10.0.20240515", ] lint = [ - "black>=23.3.0", "mypy>=1.3.0", "ruff>=0.0.272", { include-group = "types" }, @@ -93,9 +92,5 @@ target-version = "py311" select = ["E", "F", "I"] per-file-ignores = { "test_main.py" = ["F403"] } -[tool.black] -line-length = 120 -target-version = ['py311'] - [tool.pytest.ini_options] markers = ["providers", "ov_device_ids"] diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 99992b8d16..d0b502283f 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -85,43 +85,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] -[[package]] -name = "black" -version = "25.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" }, - { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" }, - { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" }, - { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" }, - { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" }, - { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" }, - { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, - { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, - { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, - { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, - { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, - { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, - { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, -] - [[package]] name = "blinker" version = "1.7.0" @@ -919,7 +882,7 @@ wheels = [ [[package]] name = "immich-ml" -version = "2.5.2" +version = "2.5.5" source = { editable = "." } dependencies = [ { name = "aiocache" }, @@ -961,7 +924,6 @@ rknn = [ [package.dev-dependencies] dev = [ - { name = "black" }, { name = "httpx" }, { name = "locust" }, { name = "mypy" }, @@ -977,7 +939,6 @@ dev = [ { name = "types-ujson" }, ] lint = [ - { name = "black" }, { name = "mypy" }, { name = "ruff" }, { name = "types-pyyaml" }, @@ -1031,7 +992,6 @@ provides-extras = ["cpu", "cuda", "openvino", "armnn", "rknn", "rocm"] [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=23.3.0" }, { name = "httpx", specifier = ">=0.24.1" }, { name = "locust", specifier = ">=2.15.1" }, { name = "mypy", specifier = ">=1.3.0" }, @@ -1047,7 +1007,6 @@ dev = [ { name = "types-ujson", specifier = ">=5.10.0.20240515" }, ] lint = [ - { name = "black", specifier = ">=23.3.0" }, { name = "mypy", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.0.272" }, { name = "types-pyyaml", specifier = ">=6.0.12.20241230" }, @@ -2232,15 +2191,6 @@ client = [ { name = "websocket-client" }, ] -[[package]] -name = "pytokens" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, -] - [[package]] name = "pywin32" version = "311" diff --git a/mise.toml b/mise.toml index 652a8afaf2..0e7237be20 100644 --- a/mise.toml +++ b/mise.toml @@ -18,8 +18,8 @@ node = "24.13.0" flutter = "3.35.7" pnpm = "10.28.0" terragrunt = "0.98.0" -opentofu = "1.10.7" -java = "25.0.1" +opentofu = "1.11.4" +java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] version = "1.30.0" diff --git a/mobile/.fvmrc b/mobile/.fvmrc deleted file mode 100644 index e8b4151592..0000000000 --- a/mobile/.fvmrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "flutter": "3.35.7" -} \ No newline at end of file diff --git a/mobile/.gitignore b/mobile/.gitignore index 484c3f0afc..04eb74fddd 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -55,8 +55,5 @@ default.isar default.isar.lock libisar.so -# FVM Version -.fvm/ - # Translation file -lib/generated/ \ No newline at end of file +lib/generated/ diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index 3092c4565f..eafbef8102 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -2,7 +2,9 @@ "dart.flutterSdkPath": ".fvm/versions/3.35.7", "dart.lineLength": 120, "[dart]": { - "editor.rulers": [120] + "editor.rulers": [ + 120 + ] }, "search.exclude": { "**/.fvm": true diff --git a/mobile/README.md b/mobile/README.md index 59b2d9340c..1f0860ced6 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -4,10 +4,12 @@ The Immich mobile app is a Flutter-based solution leveraging the Isar Database f ## Setup -1. Setup Flutter toolchain using FVM. -2. Run `flutter pub get` to install the dependencies. -3. Run `make translation` to generate the translation file. -4. Run `fvm flutter run` to start the app. +1. [Install mise](https://mise.jdx.dev/installing-mise.html). +2. Change to the immich directory and trust the mise config with `mise trust`. +3. Install tools with mise: `mise install`. +4. Run `flutter pub get` to install the dependencies. +5. Run `make translation` to generate the translation file. +6. Run `flutter run` to start the app. ## Translation @@ -29,7 +31,7 @@ dcm analyze lib ``` [DCM](https://dcm.dev/) is a vendor tool that needs to be downloaded manually to run locally. -Immich was provided an open source license. +Immich was provided an open source license. To use it, it is important that you do not have an active free tier license (can be verified with `dcm license`). If you have write-access to the Immich repository directly, running dcm in your clone should just work. If you are working on a clone of a fork, you need to connect to the main Immich repository as remote first: diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 3360617a3d..4999f9a7f9 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -131,6 +131,7 @@ dependencies { implementation "androidx.compose.ui:ui-tooling:$compose_version" implementation "androidx.compose.material3:material3:1.2.1" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2" + implementation "com.google.android.material:material:1.12.0" } // This is uncommented in F-Droid build script diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 0d4925077a..eacf75b7ed 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,8 @@ + android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false" + android:networkSecurityConfig="@xml/network_security_config"> diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt deleted file mode 100644 index 6c22f9e284..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt +++ /dev/null @@ -1,153 +0,0 @@ -package app.alextran.immich - -import android.annotation.SuppressLint -import android.content.Context -import app.alextran.immich.core.SSLConfig -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import java.io.ByteArrayInputStream -import java.net.InetSocketAddress -import java.net.Socket -import java.security.KeyStore -import java.security.cert.X509Certificate -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.KeyManager -import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLEngine -import javax.net.ssl.SSLSession -import javax.net.ssl.TrustManager -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509ExtendedTrustManager - -/** - * Android plugin for Dart `HttpSSLOptions` - */ -class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - private var methodChannel: MethodChannel? = null - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) - } - - private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { - methodChannel = MethodChannel(messenger, "immich/httpSSLOptions") - methodChannel?.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onDetachedFromEngine() - } - - private fun onDetachedFromEngine() { - methodChannel?.setMethodCallHandler(null) - methodChannel = null - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - try { - when (call.method) { - "apply" -> { - val args = call.arguments>()!! - val allowSelfSigned = args[0] as Boolean - val serverHost = args[1] as? String - val clientCertHash = (args[2] as? ByteArray) - - var tm: Array? = null - if (allowSelfSigned) { - tm = arrayOf(AllowSelfSignedTrustManager(serverHost)) - } - - var km: Array? = null - if (clientCertHash != null) { - val cert = ByteArrayInputStream(clientCertHash) - val password = (args[3] as String).toCharArray() - val keyStore = KeyStore.getInstance("PKCS12") - keyStore.load(cert, password) - val keyManagerFactory = - KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) - keyManagerFactory.init(keyStore, null) - km = keyManagerFactory.keyManagers - } - - // Update shared SSL config for OkHttp and other HTTP clients - SSLConfig.apply(km, tm, allowSelfSigned, serverHost, clientCertHash?.contentHashCode() ?: 0) - - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(km, tm, null) - HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) - - HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String)) - - result.success(true) - } - - else -> result.notImplemented() - } - } catch (e: Throwable) { - result.error("error", e.message, null) - } - } - - @SuppressLint("CustomX509TrustManager") - class AllowSelfSignedTrustManager(private val serverHost: String?) : X509ExtendedTrustManager() { - private val defaultTrustManager: X509ExtendedTrustManager = getDefaultTrustManager() - - override fun checkClientTrusted(chain: Array?, authType: String?) = - defaultTrustManager.checkClientTrusted(chain, authType) - - override fun checkClientTrusted( - chain: Array?, authType: String?, socket: Socket? - ) = defaultTrustManager.checkClientTrusted(chain, authType, socket) - - override fun checkClientTrusted( - chain: Array?, authType: String?, engine: SSLEngine? - ) = defaultTrustManager.checkClientTrusted(chain, authType, engine) - - override fun checkServerTrusted(chain: Array?, authType: String?) { - if (serverHost == null) return - defaultTrustManager.checkServerTrusted(chain, authType) - } - - override fun checkServerTrusted( - chain: Array?, authType: String?, socket: Socket? - ) { - if (serverHost == null) return - val socketAddress = socket?.remoteSocketAddress - if (socketAddress is InetSocketAddress && socketAddress.hostName == serverHost) return - defaultTrustManager.checkServerTrusted(chain, authType, socket) - } - - override fun checkServerTrusted( - chain: Array?, authType: String?, engine: SSLEngine? - ) { - if (serverHost == null || engine?.peerHost == serverHost) return - defaultTrustManager.checkServerTrusted(chain, authType, engine) - } - - override fun getAcceptedIssuers(): Array = defaultTrustManager.acceptedIssuers - - private fun getDefaultTrustManager(): X509ExtendedTrustManager { - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(null as KeyStore?) - return factory.trustManagers.filterIsInstance().first() - } - } - - class AllowSelfSignedHostnameVerifier(private val serverHost: String?) : HostnameVerifier { - companion object { - private val _defaultHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier() - } - - override fun verify(hostname: String?, session: SSLSession?): Boolean { - if (serverHost == null || hostname == serverHost) { - return true - } else { - return _defaultHostnameVerifier.verify(hostname, session) - } - } - } -} 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 08790d9772..a85929a0e9 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 @@ -9,7 +9,9 @@ import app.alextran.immich.background.BackgroundWorkerFgHostApi import app.alextran.immich.background.BackgroundWorkerLockApi import app.alextran.immich.connectivity.ConnectivityApi import app.alextran.immich.connectivity.ConnectivityApiImpl +import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.ImmichPlugin +import app.alextran.immich.core.NetworkApiPlugin import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi @@ -28,6 +30,9 @@ class MainActivity : FlutterFragmentActivity() { companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { + HttpClientManager.initialize(ctx) + flutterEngine.plugins.add(NetworkApiPlugin()) + val messenger = flutterEngine.dartExecutor.binaryMessenger val backgroundEngineLockImpl = BackgroundEngineLock(ctx) BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl) @@ -45,7 +50,6 @@ class MainActivity : FlutterFragmentActivity() { ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(nativeSyncApiImpl) } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt new file mode 100644 index 0000000000..ee92c2120e --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -0,0 +1,149 @@ +package app.alextran.immich.core + +import android.content.Context +import app.alextran.immich.BuildConfig +import okhttp3.Cache +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.OkHttpClient +import java.io.ByteArrayInputStream +import java.io.File +import java.net.Socket +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509KeyManager +import javax.net.ssl.X509TrustManager + +const val CERT_ALIAS = "client_cert" +const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" + +/** + * Manages a shared OkHttpClient with SSL configuration support. + */ +object HttpClientManager { + private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB + private const val KEEP_ALIVE_CONNECTIONS = 10 + private const val KEEP_ALIVE_DURATION_MINUTES = 5L + private const val MAX_REQUESTS_PER_HOST = 64 + + private var initialized = false + private val clientChangedListeners = mutableListOf<() -> Unit>() + + private lateinit var client: OkHttpClient + + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS) + + fun initialize(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + + val cacheDir = File(File(context.cacheDir, "okhttp"), "api") + client = build(cacheDir) + initialized = true + } + } + + fun setKeyEntry(clientData: ByteArray, password: CharArray) { + synchronized(this) { + val wasMtls = isMtls + val tmpKeyStore = KeyStore.getInstance("PKCS12").apply { + ByteArrayInputStream(clientData).use { stream -> load(stream, password) } + } + val tmpAlias = tmpKeyStore.aliases().asSequence().firstOrNull { tmpKeyStore.isKeyEntry(it) } + ?: throw IllegalArgumentException("No private key found in PKCS12") + val key = tmpKeyStore.getKey(tmpAlias, password) + val chain = tmpKeyStore.getCertificateChain(tmpAlias) + + if (wasMtls) { + keyStore.deleteEntry(CERT_ALIAS) + } + keyStore.setKeyEntry(CERT_ALIAS, key, null, chain) + if (wasMtls != isMtls) { + clientChangedListeners.forEach { it() } + } + } + } + + fun deleteKeyEntry() { + synchronized(this) { + if (!isMtls) { + return + } + + keyStore.deleteEntry(CERT_ALIAS) + clientChangedListeners.forEach { it() } + } + } + + @JvmStatic + fun getClient(): OkHttpClient { + return client + } + + fun addClientChangedListener(listener: () -> Unit) { + synchronized(this) { clientChangedListeners.add(listener) } + } + + private fun build(cacheDir: File): OkHttpClient { + val connectionPool = ConnectionPool( + maxIdleConnections = KEEP_ALIVE_CONNECTIONS, + keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES, + timeUnit = TimeUnit.MINUTES + ) + + val managerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + managerFactory.init(null as KeyStore?) + val trustManager = managerFactory.trustManagers.filterIsInstance().first() + + val sslContext = SSLContext.getInstance("TLS") + .apply { init(arrayOf(DynamicKeyManager()), arrayOf(trustManager), null) } + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) + + return OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build()) + } + .connectionPool(connectionPool) + .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) + .cache(Cache(cacheDir.apply { mkdirs() }, CACHE_SIZE_BYTES)) + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + } + + // Reads from the key store rather than taking a snapshot at initialization time + private class DynamicKeyManager : X509KeyManager { + override fun getClientAliases(keyType: String, issuers: Array?): Array? = + if (isMtls) arrayOf(CERT_ALIAS) else null + + override fun chooseClientAlias( + keyTypes: Array, + issuers: Array?, + socket: Socket? + ): String? = + if (isMtls) CERT_ALIAS else null + + override fun getCertificateChain(alias: String): Array? = + keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + + override fun getPrivateKey(alias: String): PrivateKey? = + keyStore.getKey(alias, null) as? PrivateKey + + override fun getServerAliases(keyType: String, issuers: Array?): Array? = + null + + override fun chooseServerAlias( + keyType: String, + issuers: Array?, + socket: Socket? + ): String? = null + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt new file mode 100644 index 0000000000..1e7156a147 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -0,0 +1,253 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.core + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object NetworkPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ClientCertData ( + val data: ByteArray, + val password: String +) + { + companion object { + fun fromList(pigeonVar_list: List): ClientCertData { + val data = pigeonVar_list[0] as ByteArray + val password = pigeonVar_list[1] as String + return ClientCertData(data, password) + } + } + fun toList(): List { + return listOf( + data, + password, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ClientCertData) { + return false + } + if (this === other) { + return true + } + return NetworkPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ClientCertPrompt ( + val title: String, + val message: String, + val cancel: String, + val confirm: String +) + { + companion object { + fun fromList(pigeonVar_list: List): ClientCertPrompt { + val title = pigeonVar_list[0] as String + val message = pigeonVar_list[1] as String + val cancel = pigeonVar_list[2] as String + val confirm = pigeonVar_list[3] as String + return ClientCertPrompt(title, message, cancel, confirm) + } + } + fun toList(): List { + return listOf( + title, + message, + cancel, + confirm, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ClientCertPrompt) { + return false + } + if (this === other) { + return true + } + return NetworkPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class NetworkPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + ClientCertData.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + ClientCertPrompt.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is ClientCertData -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is ClientCertPrompt -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface NetworkApi { + fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) + fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) + fun removeCertificate(callback: (Result) -> Unit) + + companion object { + /** The codec used by NetworkApi. */ + val codec: MessageCodec by lazy { + NetworkPigeonCodec() + } + /** Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val clientDataArg = args[0] as ClientCertData + api.addCertificate(clientDataArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(NetworkPigeonUtils.wrapError(error)) + } else { + reply.reply(NetworkPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val promptTextArg = args[0] as ClientCertPrompt + api.selectCertificate(promptTextArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(NetworkPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(NetworkPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.removeCertificate{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(NetworkPigeonUtils.wrapError(error)) + } else { + reply.reply(NetworkPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt new file mode 100644 index 0000000000..4f25896b2f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -0,0 +1,159 @@ +package app.alextran.immich.core + +import android.app.Activity +import android.content.Context +import android.net.Uri +import android.os.OperationCanceledException +import android.text.InputType +import android.view.ContextThemeWrapper +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +class NetworkApiPlugin : FlutterPlugin, ActivityAware { + private var networkApi: NetworkApiImpl? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + networkApi = NetworkApiImpl(binding.applicationContext) + NetworkApi.setUp(binding.binaryMessenger, networkApi) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + NetworkApi.setUp(binding.binaryMessenger, null) + networkApi = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + networkApi?.onAttachedToActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + networkApi?.onDetachedFromActivityForConfigChanges() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + networkApi?.onReattachedToActivityForConfigChanges(binding) + } + + override fun onDetachedFromActivity() { + networkApi?.onDetachedFromActivity() + } +} + +private class NetworkApiImpl(private val context: Context) : NetworkApi { + private var activity: Activity? = null + private var pendingCallback: ((Result) -> Unit)? = null + private var filePicker: ActivityResultLauncher>? = null + private var promptText: ClientCertPrompt? = null + + fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + (binding.activity as? ComponentActivity)?.let { componentActivity -> + filePicker = componentActivity.registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) } + } + } + + fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + fun onDetachedFromActivity() { + activity = null + } + + override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { + try { + HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray()) + callback(Result.success(Unit)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + + override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) { + val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) + pendingCallback = callback + this.promptText = promptText + picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file")) + } + + override fun removeCertificate(callback: (Result) -> Unit) { + HttpClientManager.deleteKeyEntry() + callback(Result.success(Unit)) + } + + private fun handlePickedFile(uri: Uri) { + val callback = pendingCallback ?: return + pendingCallback = null + + try { + val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalStateException("Could not read file") + + val activity = activity ?: throw IllegalStateException("No activity") + promptForPassword(activity) { password -> + promptText = null + if (password == null) { + callback(Result.failure(OperationCanceledException())) + return@promptForPassword + } + try { + HttpClientManager.setKeyEntry(data, password.toCharArray()) + callback(Result.success(ClientCertData(data, password))) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + + private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) { + val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog) + val density = activity.resources.displayMetrics.density + val horizontalPadding = (24 * density).toInt() + + val textInputLayout = TextInputLayout(themedContext).apply { + hint = "Password" + endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + setMargins(horizontalPadding, 0, horizontalPadding, 0) + } + } + + val editText = TextInputEditText(textInputLayout.context).apply { + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + textInputLayout.addView(editText) + + val container = FrameLayout(themedContext).apply { addView(textInputLayout) } + + val text = promptText!! + MaterialAlertDialogBuilder(themedContext) + .setTitle(text.title) + .setMessage(text.message) + .setView(container) + .setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) } + .setNegativeButton(text.cancel) { _, _ -> callback(null) } + .setOnCancelListener { callback(null) } + .show() + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt deleted file mode 100644 index f62042cd99..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt +++ /dev/null @@ -1,73 +0,0 @@ -package app.alextran.immich.core - -import java.security.KeyStore -import javax.net.ssl.KeyManager -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.TrustManager -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager - -/** - * Shared SSL configuration for OkHttp and HttpsURLConnection. - * Stores the SSLSocketFactory and X509TrustManager configured by HttpSSLOptionsPlugin. - */ -object SSLConfig { - var sslSocketFactory: SSLSocketFactory? = null - private set - - var trustManager: X509TrustManager? = null - private set - - var requiresCustomSSL: Boolean = false - private set - - private val listeners = mutableListOf<() -> Unit>() - private var configHash: Int = 0 - - fun addListener(listener: () -> Unit) { - listeners.add(listener) - } - - fun apply( - keyManagers: Array?, - trustManagers: Array?, - allowSelfSigned: Boolean, - serverHost: String?, - clientCertHash: Int - ) { - synchronized(this) { - val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash) - val newRequiresCustomSSL = allowSelfSigned || keyManagers != null - if (newHash == configHash && sslSocketFactory != null && requiresCustomSSL == newRequiresCustomSSL) { - return // Config unchanged, skip - } - - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(keyManagers, trustManagers, null) - sslSocketFactory = sslContext.socketFactory - trustManager = trustManagers?.filterIsInstance()?.firstOrNull() - ?: getDefaultTrustManager() - requiresCustomSSL = newRequiresCustomSSL - configHash = newHash - notifyListeners() - } - } - - private fun computeHash(allowSelfSigned: Boolean, serverHost: String?, clientCertHash: Int): Int { - var result = allowSelfSigned.hashCode() - result = 31 * result + (serverHost?.hashCode() ?: 0) - result = 31 * result + clientCertHash - return result - } - - private fun notifyListeners() { - listeners.forEach { it() } - } - - private fun getDefaultTrustManager(): X509TrustManager { - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(null as KeyStore?) - return factory.trustManagers.filterIsInstance().first() - } -} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 6800b45a70..04a181cd6e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -3,17 +3,15 @@ package app.alextran.immich.images import android.content.Context import android.os.CancellationSignal import android.os.OperationCanceledException -import app.alextran.immich.BuildConfig import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeByteBuffer -import app.alextran.immich.core.SSLConfig +import app.alextran.immich.core.HttpClientManager +import app.alextran.immich.core.USER_AGENT import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Call import okhttp3.Callback -import okhttp3.ConnectionPool -import okhttp3.Dispatcher import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -32,15 +30,8 @@ import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.X509TrustManager -private const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" -private const val MAX_REQUESTS_PER_HOST = 64 -private const val KEEP_ALIVE_CONNECTIONS = 10 -private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 private class RemoteRequest(val cancellationSignal: CancellationSignal) @@ -121,7 +112,7 @@ private object ImageFetcherManager { appContext = context.applicationContext cacheDir = context.cacheDir fetcher = build() - SSLConfig.addListener(::invalidate) + HttpClientManager.addClientChangedListener(::invalidate) initialized = true } } @@ -143,18 +134,14 @@ private object ImageFetcherManager { private fun invalidate() { synchronized(this) { val oldFetcher = fetcher - if (oldFetcher is OkHttpImageFetcher && SSLConfig.requiresCustomSSL) { - fetcher = oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager) - return - } fetcher = build() oldFetcher.drain() } } private fun build(): ImageFetcher { - return if (SSLConfig.requiresCustomSSL) { - OkHttpImageFetcher.create(cacheDir, SSLConfig.sslSocketFactory, SSLConfig.trustManager) + return if (HttpClientManager.isMtls) { + OkHttpImageFetcher.create(cacheDir) } else { CronetImageFetcher(appContext, cacheDir) } @@ -380,51 +367,17 @@ private class OkHttpImageFetcher private constructor( private var draining = false companion object { - fun create( - cacheDir: File, - sslSocketFactory: SSLSocketFactory?, - trustManager: X509TrustManager?, - ): OkHttpImageFetcher { + fun create(cacheDir: File): OkHttpImageFetcher { val dir = File(cacheDir, "okhttp") - val connectionPool = ConnectionPool( - maxIdleConnections = KEEP_ALIVE_CONNECTIONS, - keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES, - timeUnit = TimeUnit.MINUTES - ) - val builder = OkHttpClient.Builder() - .addInterceptor { chain -> - chain.proceed( - chain.request().newBuilder() - .header("User-Agent", USER_AGENT) - .build() - ) - } - .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) - .connectionPool(connectionPool) + val client = HttpClientManager.getClient().newBuilder() .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + .build() - if (sslSocketFactory != null && trustManager != null) { - builder.sslSocketFactory(sslSocketFactory, trustManager) - } - - return OkHttpImageFetcher(builder.build()) + return OkHttpImageFetcher(client) } } - fun reconfigure( - sslSocketFactory: SSLSocketFactory?, - trustManager: X509TrustManager?, - ): OkHttpImageFetcher { - val builder = client.newBuilder() - if (sslSocketFactory != null && trustManager != null) { - builder.sslSocketFactory(sslSocketFactory, trustManager) - } - // Evict idle connections using old SSL config - client.connectionPool.evictAll() - return OkHttpImageFetcher(builder.build()) - } - private fun onComplete() { val shouldClose = synchronized(stateLock) { activeCount-- @@ -512,7 +465,6 @@ private class OkHttpImageFetcher private constructor( draining = true activeCount == 0 } - client.connectionPool.evictAll() if (shouldClose) { client.cache?.close() } diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..8a76775f86 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 006a75c139..befc2c11c5 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3033, - "android.injected.version.name" => "2.5.2", + "android.injected.version.code" => 3036, + "android.injected.version.name" => "2.5.5", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index ca0166a382..a236b027f5 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -121,4 +121,6 @@ post_install do |installer| end # End of the permission_handler configuration end + system("defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES") + system("defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES") end diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 77caaeceef..e1ec4aff07 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -11,40 +11,6 @@ PODS: - FlutterMacOS - device_info_plus (0.0.1): - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - Flutter (1.0.0) - flutter_local_notifications (0.0.1): - Flutter @@ -93,9 +59,6 @@ PODS: - Flutter - FlutterMacOS - SAMKeychain (1.5.3) - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) - share_handler_ios (0.0.14): - Flutter - share_handler_ios/share_handler_ios_models (= 0.0.14) @@ -131,7 +94,6 @@ PODS: - sqlite3/fts5 - sqlite3/perf-threadsafe - sqlite3/rtree - - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter - wakelock_plus (0.0.1): @@ -143,7 +105,6 @@ DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) @@ -176,13 +137,9 @@ DEPENDENCIES: SPEC REPOS: trunk: - - DKImagePickerController - - DKPhotoGallery - MapLibre - SAMKeychain - - SDWebImage - sqlite3 - - SwiftyGif EXTERNAL SOURCES: background_downloader: @@ -195,8 +152,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/cupertino_http/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_local_notifications: @@ -262,9 +217,6 @@ SPEC CHECKSUMS: connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf @@ -288,7 +240,6 @@ SPEC CHECKSUMS: permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a @@ -296,10 +247,9 @@ SPEC CHECKSUMS: sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 -PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45 +PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe COCOAPODS: 1.16.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 991f075ad9..22a7abcbac 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; }; FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; }; FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; }; + FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */; }; FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; @@ -124,6 +125,7 @@ FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = ""; }; FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = ""; }; FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = ""; }; + FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -325,6 +327,7 @@ FED3B1952E253E9B0030FD97 /* Images */ = { isa = PBXGroup; children = ( + FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */, FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */, FE5499F52F11980E006016CB /* LocalImagesImpl.swift */, FE5499F12F1197D8006016CB /* LocalImages.g.swift */, @@ -609,6 +612,7 @@ FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */, FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */, FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */, + FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index ff8a53ff4b..4962230c22 100644 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", - "version" : "7.8.0" + "revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26", + "version" : "7.9.0" } }, { @@ -24,17 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/sqlite-data", "state" : { - "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" + "revision" : "05704b563ecb7f0bd7e49b6f360a6383a3e53e7d", + "version" : "1.5.1" } }, { @@ -132,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756", - "version" : "0.25.0" + "revision" : "d8163b3a98f3c8434c4361e85126db449d84bc66", + "version" : "0.30.0" } }, { @@ -145,15 +136,6 @@ "version" : "602.0.0" } }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 60f97b6645..f842285b23 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -15,12 +15,12 @@ import UIKit ) -> Bool { // Required for flutter_local_notification if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController - AppDelegate.registerPlugins(with: controller.engine) + AppDelegate.registerPlugins(with: controller.engine, controller: controller) BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) BackgroundServicePlugin.registerBackgroundProcessing() @@ -51,12 +51,13 @@ import UIKit return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - public static func registerPlugins(with engine: FlutterEngine) { + public static func registerPlugins(with engine: FlutterEngine, controller: FlutterViewController?) { NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl()) RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl()) + NetworkApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: NetworkApiImpl(viewController: controller)) } public static func cancelPlugins(with engine: FlutterEngine) { diff --git a/mobile/ios/Runner/Background/BackgroundWorker.swift b/mobile/ios/Runner/Background/BackgroundWorker.swift index 7dc450d76e..85e1a55d3d 100644 --- a/mobile/ios/Runner/Background/BackgroundWorker.swift +++ b/mobile/ios/Runner/Background/BackgroundWorker.swift @@ -95,7 +95,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi { // Register plugins in the new engine GeneratedPluginRegistrant.register(with: engine) // Register custom plugins - AppDelegate.registerPlugins(with: engine) + AppDelegate.registerPlugins(with: engine, controller: nil) flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger) BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift new file mode 100644 index 0000000000..0f678ce4a4 --- /dev/null +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -0,0 +1,284 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsNetwork(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsNetwork(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsNetwork(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashNetwork(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashNetwork(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashNetwork(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct ClientCertData: Hashable { + var data: FlutterStandardTypedData + var password: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertData? { + let data = pigeonVar_list[0] as! FlutterStandardTypedData + let password = pigeonVar_list[1] as! String + + return ClientCertData( + data: data, + password: password + ) + } + func toList() -> [Any?] { + return [ + data, + password, + ] + } + static func == (lhs: ClientCertData, rhs: ClientCertData) -> Bool { + return deepEqualsNetwork(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNetwork(value: toList(), hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct ClientCertPrompt: Hashable { + var title: String + var message: String + var cancel: String + var confirm: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertPrompt? { + let title = pigeonVar_list[0] as! String + let message = pigeonVar_list[1] as! String + let cancel = pigeonVar_list[2] as! String + let confirm = pigeonVar_list[3] as! String + + return ClientCertPrompt( + title: title, + message: message, + cancel: cancel, + confirm: confirm + ) + } + func toList() -> [Any?] { + return [ + title, + message, + cancel, + confirm, + ] + } + static func == (lhs: ClientCertPrompt, rhs: ClientCertPrompt) -> Bool { + return deepEqualsNetwork(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNetwork(value: toList(), hasher: &hasher) + } +} + +private class NetworkPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return ClientCertData.fromList(self.readValue() as! [Any?]) + case 130: + return ClientCertPrompt.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NetworkPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? ClientCertData { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? ClientCertPrompt { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NetworkPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NetworkPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NetworkPigeonCodecWriter(data: data) + } +} + +class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NetworkPigeonCodec(readerWriter: NetworkPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NetworkApi { + func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) + func removeCertificate(completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NetworkApiSetup { + static var codec: FlutterStandardMessageCodec { NetworkPigeonCodec.shared } + /// Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let addCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + addCertificateChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let clientDataArg = args[0] as! ClientCertData + api.addCertificate(clientData: clientDataArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + addCertificateChannel.setMessageHandler(nil) + } + let selectCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + selectCertificateChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let promptTextArg = args[0] as! ClientCertPrompt + api.selectCertificate(promptText: promptTextArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + selectCertificateChannel.setMessageHandler(nil) + } + let removeCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + removeCertificateChannel.setMessageHandler { _, reply in + api.removeCertificate { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + removeCertificateChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift new file mode 100644 index 0000000000..d67c392a3a --- /dev/null +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -0,0 +1,157 @@ +import Foundation +import UniformTypeIdentifiers + +enum ImportError: Error { + case noFile + case noViewController + case keychainError(OSStatus) + case cancelled +} + +class NetworkApiImpl: NetworkApi { + weak var viewController: UIViewController? + private var activeImporter: CertImporter? + + init(viewController: UIViewController?) { + self.viewController = viewController + } + + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { + let importer = CertImporter(promptText: promptText, completion: { [weak self] result in + self?.activeImporter = nil + completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) + }, viewController: viewController) + activeImporter = importer + importer.load() + } + + func removeCertificate(completion: @escaping (Result) -> Void) { + let status = clearCerts() + if status == errSecSuccess || status == errSecItemNotFound { + return completion(.success(())) + } + completion(.failure(ImportError.keychainError(status))) + } + + func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) { + let status = importCert(clientData: clientData.data.data, password: clientData.password) + if status == errSecSuccess { + return completion(.success(())) + } + completion(.failure(ImportError.keychainError(status))) + } +} + +private class CertImporter: NSObject, UIDocumentPickerDelegate { + private let promptText: ClientCertPrompt + private var completion: ((Result<(Data, String), Error>) -> Void) + private weak var viewController: UIViewController? + + init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { + self.promptText = promptText + self.completion = completion + self.viewController = viewController + } + + func load() { + guard let vc = viewController else { return completion(.failure(ImportError.noViewController)) } + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [ + UTType(filenameExtension: "p12")!, + UTType(filenameExtension: "pfx")!, + ]) + picker.delegate = self + picker.allowsMultipleSelection = false + vc.present(picker, animated: true) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + return completion(.failure(ImportError.noFile)) + } + + Task { @MainActor in + do { + let data = try readSecurityScoped(url: url) + guard let password = await promptForPassword() else { + return completion(.failure(ImportError.cancelled)) + } + let status = importCert(clientData: data, password: password) + if status != errSecSuccess { + return completion(.failure(ImportError.keychainError(status))) + } + + await URLSessionManager.shared.session.flush() + self.completion(.success((data, password))) + } catch { + completion(.failure(error)) + } + } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + completion(.failure(ImportError.cancelled)) + } + + private func promptForPassword() async -> String? { + guard let vc = viewController else { return nil } + + return await withCheckedContinuation { continuation in + let alert = UIAlertController( + title: promptText.title, + message: promptText.message, + preferredStyle: .alert + ) + + alert.addTextField { $0.isSecureTextEntry = true } + + alert.addAction(UIAlertAction(title: promptText.cancel, style: .cancel) { _ in + continuation.resume(returning: nil) + }) + + alert.addAction(UIAlertAction(title: promptText.confirm, style: .default) { _ in + continuation.resume(returning: alert.textFields?.first?.text ?? "") + }) + + vc.present(alert, animated: true) + } + } + + private func readSecurityScoped(url: URL) throws -> Data { + guard url.startAccessingSecurityScopedResource() else { + throw ImportError.noFile + } + defer { url.stopAccessingSecurityScopedResource() } + return try Data(contentsOf: url) + } +} + +private func importCert(clientData: Data, password: String) -> OSStatus { + let options = [kSecImportExportPassphrase: password] as CFDictionary + var items: CFArray? + let status = SecPKCS12Import(clientData as CFData, options, &items) + + guard status == errSecSuccess, + let array = items as? [[String: Any]], + let first = array.first, + let identity = first[kSecImportItemIdentity as String] else { + return status + } + + clearCerts() + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecValueRef as String: identity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + return SecItemAdd(addQuery as CFDictionary, nil) +} + +@discardableResult private func clearCerts() -> OSStatus { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + ] + return SecItemDelete(deleteQuery as CFDictionary) +} diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift new file mode 100644 index 0000000000..73145dbce5 --- /dev/null +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -0,0 +1,87 @@ +import Foundation + +let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" + +/// Manages a shared URLSession with SSL configuration support. +class URLSessionManager: NSObject { + static let shared = URLSessionManager() + + let session: URLSession + private let configuration = { + let config = URLSessionConfiguration.default + + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("api", isDirectory: true) + try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + config.urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 1024 * 1024 * 1024, + directory: cacheDir + ) + + config.httpMaximumConnectionsPerHost = 64 + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] + + return config + }() + + private override init() { + session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) + super.init() + } +} + +class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + func handleChallenge( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler) + default: completionHandler(.performDefaultHandling, nil) + } + } + + private func handleClientCertificate( + completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecReturnRef as String: true, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess, let identity = item { + let credential = URLCredential(identity: identity as! SecIdentity, + certificates: nil, + persistence: .forSession) + return completion(.useCredential, credential) + } + completion(.performDefaultHandling, nil) + } +} diff --git a/mobile/ios/Runner/Images/ImageProcessing.swift b/mobile/ios/Runner/Images/ImageProcessing.swift new file mode 100644 index 0000000000..2270bbffac --- /dev/null +++ b/mobile/ios/Runner/Images/ImageProcessing.swift @@ -0,0 +1,7 @@ +import Foundation + +enum ImageProcessing { + static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent) + static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2) + static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) +} diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 4f2090443a..96e1b60a2f 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -34,7 +34,6 @@ class LocalImageApiImpl: LocalImageApi { private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated) private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated) private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) - private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent) private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, @@ -44,8 +43,6 @@ class LocalImageApiImpl: LocalImageApi { renderingIntent: .defaultIntent )! private static var requests = [Int64: LocalImageRequest]() - private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) - private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2) private static let assetCache = { let assetCache = NSCache() assetCache.countLimit = 10000 @@ -53,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi { }() func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { - Self.processingQueue.async { + ImageProcessing.queue.async { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} @@ -71,16 +68,16 @@ class LocalImageApiImpl: LocalImageApi { let request = LocalImageRequest(callback: completion) let item = DispatchWorkItem { if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } - Self.concurrencySemaphore.wait() + ImageProcessing.semaphore.wait() defer { - Self.concurrencySemaphore.signal() + ImageProcessing.semaphore.signal() } if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } guard let asset = Self.requestAsset(assetId: assetId) @@ -91,7 +88,7 @@ class LocalImageApiImpl: LocalImageApi { } if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } var image: UIImage? @@ -106,7 +103,7 @@ class LocalImageApiImpl: LocalImageApi { ) if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } guard let image = image, @@ -116,7 +113,7 @@ class LocalImageApiImpl: LocalImageApi { } if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } do { @@ -124,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi { if request.isCancelled { buffer.free() - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } request.callback(.success([ @@ -133,7 +130,6 @@ class LocalImageApiImpl: LocalImageApi { "height": Int64(buffer.height), "rowBytes": Int64(buffer.rowBytes) ])) - print("Successful response for \(requestId)") Self.remove(requestId: requestId) } catch { Self.remove(requestId: requestId) @@ -143,7 +139,7 @@ class LocalImageApiImpl: LocalImageApi { request.workItem = item Self.add(requestId: requestId, request: request) - Self.processingQueue.async(execute: item) + ImageProcessing.queue.async(execute: item) } func cancelRequest(requestId: Int64) { @@ -164,7 +160,7 @@ class LocalImageApiImpl: LocalImageApi { request.isCancelled = true guard let item = request.workItem else { return } if item.isCancelled { - cancelQueue.async { request.callback(Self.cancelledResult) } + cancelQueue.async { request.callback(ImageProcessing.cancelledResult) } } } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index d59204b96e..56e8938521 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -7,64 +7,18 @@ class RemoteImageRequest { weak var task: URLSessionDataTask? let id: Int64 var isCancelled = false - var data: CFMutableData? let completion: (Result<[String: Int64]?, any Error>) -> Void init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.id = id self.task = task - self.data = nil self.completion = completion } } class RemoteImageApiImpl: NSObject, RemoteImageApi { - private static let delegate = RemoteImageApiDelegate() - static let session = { - let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true) - let config = URLSessionConfiguration.default - config.requestCachePolicy = .returnCacheDataElseLoad - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" - config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] - try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - config.urlCache = URLCache( - memoryCapacity: 0, - diskCapacity: 1 << 30, - directory: cacheDir - ) - config.httpMaximumConnectionsPerHost = 64 - return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - }() - - func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { - var urlRequest = URLRequest(url: URL(string: url)!) - for (key, value) in headers { - urlRequest.setValue(value, forHTTPHeaderField: key) - } - let task = Self.session.dataTask(with: urlRequest) - - let imageRequest = RemoteImageRequest(id: requestId, task: task, completion: completion) - Self.delegate.add(taskId: task.taskIdentifier, request: imageRequest) - - task.resume() - } - - func cancelRequest(requestId: Int64) { - Self.delegate.cancel(requestId: requestId) - } - - func clearCache(completion: @escaping (Result) -> Void) { - Task { - let cache = Self.session.configuration.urlCache! - let cacheSize = Int64(cache.currentDiskUsage) - cache.removeAllCachedResponses() - completion(.success(cacheSize)) - } - } -} - -class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { - private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent) + private static var lock = os_unfair_lock() + private static var requests = [Int64: RemoteImageRequest]() private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, @@ -72,9 +26,6 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), renderingIntent: .perceptual )! - private static var requestByTaskId = [Int: RemoteImageRequest]() - private static var taskIdByRequestId = [Int64: Int]() - private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) private static let decodeOptions = [ kCGImageSourceShouldCache: false, kCGImageSourceShouldCacheImmediately: true, @@ -82,105 +33,103 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { kCGImageSourceCreateThumbnailFromImageAlways: true ] as CFDictionary - func urlSession( - _ session: URLSession, dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void - ) { - guard let request = get(taskId: dataTask.taskIdentifier) - else { - return completionHandler(.cancel) + func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + var urlRequest = URLRequest(url: URL(string: url)!) + urlRequest.cachePolicy = .returnCacheDataElseLoad + for (key, value) in headers { + urlRequest.setValue(value, forHTTPHeaderField: key) } - let capacity = max(Int(response.expectedContentLength), 0) - request.data = CFDataCreateMutable(nil, capacity) - - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, - didReceive data: Data) { - guard let request = get(taskId: dataTask.taskIdentifier) else { return } - - data.withUnsafeBytes { bytes in - CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count) + let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in + Self.handleCompletion(requestId: requestId, data: data, response: response, error: error) } + + let request = RemoteImageRequest(id: requestId, task: task, completion: completion) + + os_unfair_lock_lock(&Self.lock) + Self.requests[requestId] = request + os_unfair_lock_unlock(&Self.lock) + + task.resume() } - func urlSession(_ session: URLSession, task: URLSessionTask, - didCompleteWithError error: Error?) { - guard let request = get(taskId: task.taskIdentifier) else { return } - - defer { remove(taskId: task.taskIdentifier, requestId: request.id) } + private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) { + os_unfair_lock_lock(&Self.lock) + guard let request = requests[requestId] else { + return os_unfair_lock_unlock(&Self.lock) + } + requests[requestId] = nil + os_unfair_lock_unlock(&Self.lock) if let error = error { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { - return request.completion(Self.cancelledResult) + return request.completion(ImageProcessing.cancelledResult) } return request.completion(.failure(error)) } if request.isCancelled { - return request.completion(Self.cancelledResult) + return request.completion(ImageProcessing.cancelledResult) } - guard let data = request.data else { + guard let data = data else { return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } - guard let imageSource = CGImageSourceCreateWithData(data, nil), - let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else { - return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) - } - - if request.isCancelled { - return request.completion(Self.cancelledResult) - } - - do { - let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) + ImageProcessing.queue.async { + ImageProcessing.semaphore.wait() + defer { ImageProcessing.semaphore.signal() } if request.isCancelled { - buffer.free() - return request.completion(Self.cancelledResult) + return request.completion(ImageProcessing.cancelledResult) } - request.completion( - .success([ - "pointer": Int64(Int(bitPattern: buffer.data)), - "width": Int64(buffer.width), - "height": Int64(buffer.height), - "rowBytes": Int64(buffer.rowBytes), - ])) - } catch { - return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { + return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) + } + + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } + + do { + let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) + + if request.isCancelled { + buffer.free() + return request.completion(ImageProcessing.cancelledResult) + } + + request.completion( + .success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes), + ])) + } catch { + return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) + } } } - @inline(__always) func get(taskId: Int) -> RemoteImageRequest? { - Self.requestQueue.sync { Self.requestByTaskId[taskId] } - } - - @inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void { - Self.requestQueue.async(flags: .barrier) { - Self.requestByTaskId[taskId] = request - Self.taskIdByRequestId[request.id] = taskId - } - } - - @inline(__always) func remove(taskId: Int, requestId: Int64) -> Void { - Self.requestQueue.async(flags: .barrier) { - Self.taskIdByRequestId[requestId] = nil - Self.requestByTaskId[taskId] = nil - } - } - - @inline(__always) func cancel(requestId: Int64) -> Void { - guard let request: RemoteImageRequest = (Self.requestQueue.sync { - guard let taskId = Self.taskIdByRequestId[requestId] else { return nil } - return Self.requestByTaskId[taskId] - }) else { return } + func cancelRequest(requestId: Int64) { + os_unfair_lock_lock(&Self.lock) + let request = Self.requests[requestId] + os_unfair_lock_unlock(&Self.lock) + + guard let request = request else { return } request.isCancelled = true request.task?.cancel() } + + func clearCache(completion: @escaping (Result) -> Void) { + Task { + let cache = URLSessionManager.shared.session.configuration.urlCache! + let cacheSize = Int64(cache.currentDiskUsage) + cache.removeAllCachedResponses() + completion(.success(cacheSize)) + } + } } diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 48320afa3f..e0f06612fa 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -80,7 +80,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.5.2 + 2.5.5 CFBundleSignature ???? CFBundleURLTypes diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 9019db664d..6de13b6244 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -88,7 +88,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - HttpSSLOptions.apply(applyNative: false); + HttpSSLOptions.apply(); await Future.wait( [ diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index 8be3c2f224..6781507566 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -41,7 +41,7 @@ class HashService { final Stopwatch stopwatch = Stopwatch()..start(); try { // Migrate hashes from cloud ID to local ID so we don't have to re-hash them - await _localAssetRepository.reconcileHashesFromCloudId(); + // await _localAssetRepository.reconcileHashesFromCloudId(); // Sorted by backupSelection followed by isCloud final localAlbums = await _localAlbumRepository.getBackupAlbums(); diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index e4a129d322..868f153157 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -19,6 +19,7 @@ import 'package:logging/logging.dart'; class LocalSyncService { final DriftLocalAlbumRepository _localAlbumRepository; + // ignore: unused_field final DriftLocalAssetRepository _localAssetRepository; final NativeSyncApi _nativeSyncApi; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; @@ -53,8 +54,8 @@ class LocalSyncService { } if (CurrentPlatform.isIOS) { - final assets = await _localAssetRepository.getEmptyCloudIdAssets(); - await _mapIosCloudIds(assets); + // final assets = await _localAssetRepository.getEmptyCloudIdAssets(); + // await _mapIosCloudIds(assets); } if (full || await _nativeSyncApi.shouldFullSync()) { @@ -306,27 +307,28 @@ class LocalSyncService { return true; } + // ignore: avoid-unused-parameters Future _mapIosCloudIds(List assets) async { - if (!CurrentPlatform.isIOS || assets.isEmpty) { - return; - } + // if (!CurrentPlatform.isIOS || assets.isEmpty) { + return; + // } - final assetIds = assets.map((a) => a.id).toList(); - final cloudMapping = {}; - final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds); - for (int i = 0; i < cloudIds.length; i++) { - final cloudIdResult = cloudIds[i]; - if (cloudIdResult.cloudId != null) { - cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!; - } else { - final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId); - _log.fine( - "Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}", - ); - } - } + // final assetIds = assets.map((a) => a.id).toList(); + // final cloudMapping = {}; + // final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds); + // for (int i = 0; i < cloudIds.length; i++) { + // final cloudIdResult = cloudIds[i]; + // if (cloudIdResult.cloudId != null) { + // cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!; + // } else { + // final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId); + // _log.fine( + // "Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}", + // ); + // } + // } - await _localAlbumRepository.updateCloudMapping(cloudMapping); + // await _localAlbumRepository.updateCloudMapping(cloudMapping); } bool _assetsEqual(LocalAsset a, LocalAsset b) { diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 61e114762c..bd36d0b569 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -227,6 +227,13 @@ class TimelineService { return _buffer.elementAt(index - _bufferOffset); } + /// Finds the index of an asset by its heroTag within the current buffer. + /// Returns null if the asset is not found in the buffer. + int? getIndex(String heroTag) { + final index = _buffer.indexWhere((a) => a.heroTag == heroTag); + return index >= 0 ? _bufferOffset + index : null; + } + Future dispose() async { await _bucketSubscription?.cancel(); _bucketSubscription = null; diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index 1db22b558e..217a18b75c 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -85,14 +85,14 @@ LIMIT $limit; mergedBucket(:group_by AS INTEGER): SELECT COUNT(*) as asset_count, - CASE - WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at, 'localtime') -- day - WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at, 'localtime') -- month - END AS bucket_date + bucket_date FROM ( SELECT - rae.created_at + CASE + WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', rae.local_date_time) + WHEN :group_by = 1 THEN STRFTIME('%Y-%m', rae.local_date_time) + END as bucket_date FROM remote_asset_entity rae LEFT JOIN @@ -107,7 +107,10 @@ FROM ) UNION ALL SELECT - lae.created_at + CASE + WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', lae.created_at, 'localtime') + WHEN :group_by = 1 THEN STRFTIME('%Y-%m', lae.created_at, 'localtime') + END as bucket_date FROM local_asset_entity lae WHERE NOT EXISTS ( diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index f71aa8eb54..cd2fe5cfcc 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -79,7 +79,7 @@ class MergedAssetDrift extends i1.ModularAccessor { final expandeduserIds = $expandVar($arrayStartIndex, userIds.length); $arrayStartIndex += userIds.length; return customSelect( - 'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC', + 'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', rae.local_date_time) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', rae.local_date_time) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC', variables: [ i0.Variable(groupBy), for (var $ in userIds) i0.Variable($), diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 2d30e3a0b9..652e9de943 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -228,7 +228,9 @@ class Drift extends $Drift implements IDatabaseRepository { await customStatement('PRAGMA foreign_keys = ON'); await customStatement('PRAGMA synchronous = NORMAL'); await customStatement('PRAGMA journal_mode = WAL'); - await customStatement('PRAGMA busy_timeout = 30000'); + await customStatement('PRAGMA busy_timeout = 30000'); // 30s + await customStatement('PRAGMA cache_size = -32000'); // 32MB + await customStatement('PRAGMA temp_store = MEMORY'); }, ); } diff --git a/mobile/lib/infrastructure/repositories/logger_db.repository.dart b/mobile/lib/infrastructure/repositories/logger_db.repository.dart index 583fc42813..0037f4a1e3 100644 --- a/mobile/lib/infrastructure/repositories/logger_db.repository.dart +++ b/mobile/lib/infrastructure/repositories/logger_db.repository.dart @@ -22,6 +22,7 @@ class DriftLogger extends $DriftLogger implements IDatabaseRepository { await customStatement('PRAGMA synchronous = NORMAL'); await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA busy_timeout = 500'); + await customStatement('PRAGMA temp_store = MEMORY'); }, ); } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index b0548bdd28..0e145395df 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -126,7 +126,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.localAssetEntity.id.count(); - final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy); + final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy, toLocal: true); final query = _db.localAssetEntity.selectOnly().join([ @@ -203,7 +203,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { final album = albums.first; final isAscending = album.order == AlbumAssetOrder.asc; final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -280,7 +280,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository { final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); final Map bucketCounts = {}; for (final asset in sorted) { - final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day); + final localTime = asset.createdAt.toLocal(); + final date = DateTime(localTime.year, localTime.month, localTime.day); bucketCounts[date] = (bucketCounts[date] ?? 0) + 1; } @@ -360,7 +361,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -430,7 +431,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -500,7 +501,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -602,7 +603,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -664,10 +665,11 @@ List _generateBuckets(int count) { } extension on Expression { - Expression dateFmt(GroupAssetsBy groupBy) { + Expression dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) { // DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting - // to create the correct time bucket - final localTimeExp = modify(const DateTimeModifier.localTime()); + // to create the correct time bucket when toLocal is true + // toLocal is false for remote assets where localDateTime is already in the correct timezone + final localTimeExp = toLocal ? modify(const DateTimeModifier.localTime()) : this; return switch (groupBy) { GroupAssetsBy.day || GroupAssetsBy.auto => localTimeExp.date, GroupAssetsBy.month => localTimeExp.strftime("%Y-%m"), diff --git a/mobile/lib/models/server_info/server_info.model.dart b/mobile/lib/models/server_info/server_info.model.dart index a034960ddb..5d78acb0b8 100644 --- a/mobile/lib/models/server_info/server_info.model.dart +++ b/mobile/lib/models/server_info/server_info.model.dart @@ -20,7 +20,7 @@ enum VersionStatus { class ServerInfo { final ServerVersion serverVersion; - final ServerVersion latestVersion; + final ServerVersion? latestVersion; final ServerFeatures serverFeatures; final ServerConfig serverConfig; final ServerDiskInfo serverDiskInfo; diff --git a/mobile/lib/pages/backup/failed_backup_status.page.dart b/mobile/lib/pages/backup/failed_backup_status.page.dart index b533895cd7..a97a133b89 100644 --- a/mobile/lib/pages/backup/failed_backup_status.page.dart +++ b/mobile/lib/pages/backup/failed_backup_status.page.dart @@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; -import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:intl/intl.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset; @RoutePage() class FailedBackupStatusPage extends HookConsumerWidget { @@ -58,7 +59,7 @@ class FailedBackupStatusPage extends HookConsumerWidget { clipBehavior: Clip.hardEdge, child: Image( fit: BoxFit.cover, - image: ImmichLocalThumbnailProvider(asset: errorAsset.asset, height: 512, width: 512), + image: LocalThumbProvider(id: errorAsset.asset.localId!, assetType: base_asset.AssetType.video), ), ), ), diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart index 7145bc2553..68123509ae 100644 --- a/mobile/lib/pages/common/gallery_stacked_children.dart +++ b/mobile/lib/pages/common/gallery_stacked_children.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; 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/show_controls.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; class GalleryStackedChildren extends HookConsumerWidget { final ValueNotifier stackIndex; @@ -70,7 +70,7 @@ class GalleryStackedChildren extends HookConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(4)), child: Image( fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId), + image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: asset.thumbhash ?? ""), ), ), ), diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 483427d2de..6332a662b9 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; @@ -221,12 +221,7 @@ class PeopleCollectionCard extends ConsumerWidget { mainAxisSpacing: 8, physics: const NeverScrollableScrollPhysics(), children: people.take(4).map((person) { - return CircleAvatar( - backgroundImage: NetworkImage( - getFaceThumbnailUrl(person.id), - headers: ApiService.getRequestHeaders(), - ), - ); + return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id))); }).toList(), ); }, diff --git a/mobile/lib/pages/library/people/people_collection.page.dart b/mobile/lib/pages/library/people/people_collection.page.dart index 375d4d2a96..bff52df6da 100644 --- a/mobile/lib/pages/library/people/people_collection.page.dart +++ b/mobile/lib/pages/library/people/people_collection.page.dart @@ -5,8 +5,8 @@ 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/search/people.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; @@ -17,7 +17,6 @@ class PeopleCollectionPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final people = ref.watch(getAllPeopleProvider); - final headers = ApiService.getRequestHeaders(); final formFocus = useFocusNode(); final ValueNotifier search = useState(null); @@ -88,7 +87,7 @@ class PeopleCollectionPage extends HookConsumerWidget { elevation: 3, child: CircleAvatar( maxRadius: isTablet ? 120 / 2 : 96 / 2, - backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)), ), ), ), diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index d6511cb25b..a4a6f66915 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; @@ -10,9 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -125,13 +125,10 @@ class PlaceTile extends StatelessWidget { title: Text(name, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)), leading: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20)), - child: CachedNetworkImage( + child: SizedBox( width: 80, height: 80, - fit: BoxFit.cover, - imageUrl: thumbnailUrl, - httpHeaders: ApiService.getRequestHeaders(), - errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined), + child: Thumbnail(imageProvider: RemoteImageProvider(url: thumbnailUrl)), ), ), ); diff --git a/mobile/lib/pages/search/person_result.page.dart b/mobile/lib/pages/search/person_result.page.dart index 7d2e612d25..8375eb14fd 100644 --- a/mobile/lib/pages/search/person_result.page.dart +++ b/mobile/lib/pages/search/person_result.page.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; @@ -88,10 +88,7 @@ class PersonResultPage extends HookConsumerWidget { padding: const EdgeInsets.only(left: 8.0, top: 24), child: Row( children: [ - CircleAvatar( - radius: 36, - backgroundImage: NetworkImage(getFaceThumbnailUrl(personId), headers: ApiService.getRequestHeaders()), - ), + CircleAvatar(radius: 36, backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(personId))), Expanded( child: Padding(padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: buildTitleBlock()), ), diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart new file mode 100644 index 0000000000..6ddb3cdb71 --- /dev/null +++ b/mobile/lib/platform/network_api.g.dart @@ -0,0 +1,232 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), + ); + } + return a == b; +} + +class ClientCertData { + ClientCertData({required this.data, required this.password}); + + Uint8List data; + + String password; + + List _toList() { + return [data, password]; + } + + Object encode() { + return _toList(); + } + + static ClientCertData decode(Object result) { + result as List; + return ClientCertData(data: result[0]! as Uint8List, password: result[1]! as String); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ClientCertData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class ClientCertPrompt { + ClientCertPrompt({required this.title, required this.message, required this.cancel, required this.confirm}); + + String title; + + String message; + + String cancel; + + String confirm; + + List _toList() { + return [title, message, cancel, confirm]; + } + + Object encode() { + return _toList(); + } + + static ClientCertPrompt decode(Object result) { + result as List; + return ClientCertPrompt( + title: result[0]! as String, + message: result[1]! as String, + cancel: result[2]! as String, + confirm: result[3]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ClientCertPrompt || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is ClientCertData) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is ClientCertPrompt) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return ClientCertData.decode(readValue(buffer)!); + case 130: + return ClientCertPrompt.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class NetworkApi { + /// Constructor for [NetworkApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NetworkApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future addCertificate(ClientCertData clientData) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([clientData]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future selectCertificate(ClientCertPrompt promptText) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([promptText]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as ClientCertData?)!; + } + } + + Future removeCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index fe2ab61a58..cde8c127db 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -33,7 +33,7 @@ class _DriftAlbumsPageState extends ConsumerState { @override Widget build(BuildContext context) { final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length)); - final showScrollbar = albumCount > 10; + final showScrollbar = albumCount > 20; final scrollView = CustomScrollView( controller: _scrollController, diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index d1d663e4f4..4708b5e615 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -12,8 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; @@ -179,12 +179,7 @@ class _PeopleCollectionCard extends ConsumerWidget { mainAxisSpacing: 8, physics: const NeverScrollableScrollPhysics(), children: people.take(4).map((person) { - return CircleAvatar( - backgroundImage: NetworkImage( - getFaceThumbnailUrl(person.id), - headers: ApiService.getRequestHeaders(), - ), - ); + return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id))); }).toList(), ); }, diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index ca4e20aad0..f73dac3af2 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -31,7 +31,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState(); + const preparingDialog = _SharePreparingDialog(); await showDialog( context: context, builder: (BuildContext buildContext) { - ref.read(actionProvider.notifier).shareAssets(source, context).then((ActionResult result) { - ref.read(multiSelectProvider.notifier).reset(); - - if (!context.mounted) { + ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then(( + ActionResult result, + ) { + if (cancelCompleter.isCompleted || !context.mounted) { return; } + ref.read(multiSelectProvider.notifier).reset(); + if (!result.success) { ImmichToast.show( context: context, @@ -64,11 +69,15 @@ class ShareActionButton extends ConsumerWidget { }); // show a loading spinner with a "Preparing" message - return const _SharePreparingDialog(); + return preparingDialog; }, barrierDismissible: false, useRootNavigator: false, - ); + ).then((_) { + if (!cancelCompleter.isCompleted) { + cancelCompleter.complete(); + } + }); } @override diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 4db297d658..e35fbf7433 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -87,7 +87,7 @@ class _AlbumSelectorState extends ConsumerState { } void onSearch(String searchTerm, QuickFilterMode filterMode) { - final userId = ref.watch(currentUserProvider)?.id; + final userId = ref.read(currentUserProvider)?.id; filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode); filterAlbums(); @@ -186,7 +186,7 @@ class _AlbumSelectorState extends ConsumerState { @override Widget build(BuildContext context) { - final userId = ref.watch(currentUserProvider)?.id; + final userId = ref.watch(currentUserProvider.select((user) => user?.id)); // refilter and sort when albums change ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 2be2bdf765..ed2ab9d15d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -451,21 +451,36 @@ class _AssetViewerState extends ConsumerState { } void _onTimelineReloadEvent() { - totalAssets = ref.read(timelineServiceProvider).totalAssets; + final timelineService = ref.read(timelineServiceProvider); + totalAssets = timelineService.totalAssets; + if (totalAssets == 0) { context.maybePop(); return; } + var index = pageController.page?.round() ?? 0; + final currentAsset = ref.read(currentAssetNotifier); + if (currentAsset != null) { + final newIndex = timelineService.getIndex(currentAsset.heroTag); + if (newIndex != null && newIndex != index) { + index = newIndex; + pageController.jumpToPage(index); + } + } + + if (index >= totalAssets) { + index = totalAssets - 1; + pageController.jumpToPage(index); + } + if (assetReloadRequested) { assetReloadRequested = false; - _onAssetReloadEvent(); - return; + _onAssetReloadEvent(index); } } - void _onAssetReloadEvent() async { - final index = pageController.page?.round() ?? 0; + void _onAssetReloadEvent(int index) async { final timelineService = ref.read(timelineServiceProvider); final newAsset = await timelineService.getAssetAsync(index); diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart index d62a964401..7eb9e578ff 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart @@ -10,8 +10,8 @@ import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; @@ -108,8 +108,6 @@ class _PeopleAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final headers = ApiService.getRequestHeaders(); - return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 96), child: Padding( @@ -127,7 +125,7 @@ class _PeopleAvatar extends StatelessWidget { elevation: 3, child: CircleAvatar( maxRadius: imageSize / 2, - backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)), ), ), ), diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 6e60c59c7f..be5b8b4189 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -134,7 +134,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : ""; - return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null; + return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null; } bool _shouldUseLocalAsset(BaseAsset asset) => diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 08d1ef13db..6cb68c1442 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -10,50 +10,48 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; -class RemoteThumbProvider extends CancellableImageProvider - with CancellableImageProviderMixin { - final String assetId; - final String thumbhash; +class RemoteImageProvider extends CancellableImageProvider + with CancellableImageProviderMixin { + final String url; - RemoteThumbProvider({required this.assetId, required this.thumbhash}); + RemoteImageProvider({required this.url}); + + RemoteImageProvider.thumbnail({required String assetId, required String thumbhash}) + : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash); @override - Future obtainKey(ImageConfiguration configuration) { + Future obtainKey(ImageConfiguration configuration) { return SynchronousFuture(this); } @override - ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) { + ImageStreamCompleter loadImage(RemoteImageProvider key, ImageDecoderCallback decode) { return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), informationCollector: () => [ DiagnosticsProperty('Image provider', this), - DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('URL', key.url), ], onDispose: cancel, ); } - Stream _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { - final request = this.request = RemoteImageRequest( - uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash), - headers: ApiService.getRequestHeaders(), - ); + Stream _codec(RemoteImageProvider key, ImageDecoderCallback decode) { + final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders()); return loadRequest(request, decode); } @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is RemoteThumbProvider) { - return assetId == other.assetId && thumbhash == other.thumbhash; + if (other is RemoteImageProvider) { + return url == other.url; } - return false; } @override - int get hashCode => assetId.hashCode ^ thumbhash.hashCode; + int get hashCode => url.hashCode; } class RemoteFullImageProvider extends CancellableImageProvider @@ -73,7 +71,7 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index f878c214a9..d35dd181db 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -27,7 +27,7 @@ class Thumbnail extends StatefulWidget { this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key, - }) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash), + }) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash), thumbhashProvider = null; Thumbnail.fromAsset({ diff --git a/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart b/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart index 8cdf1ed286..8b391d50c6 100644 --- a/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart +++ b/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart @@ -1,10 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; class PartnerUserAvatar extends StatelessWidget { const PartnerUserAvatar({super.key, required this.partner}); @@ -18,11 +17,7 @@ class PartnerUserAvatar extends StatelessWidget { return CircleAvatar( radius: 16, backgroundColor: context.primaryColor.withAlpha(50), - foregroundImage: CachedNetworkImageProvider( - url, - headers: ApiService.getRequestHeaders(), - cacheKey: "user-${partner.id}-profile", - ), + foregroundImage: RemoteImageProvider(url: url), // silence errors if user has no profile image, use initials as fallback onForegroundImageError: (exception, stackTrace) {}, child: Text(nameFirstLetter.toUpperCase()), diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 2f067fdf67..624c21f158 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -259,6 +259,11 @@ class DriftBackupNotifier extends StateNotifier { } Future startForegroundBackup(String userId) async { + // Cancel any existing backup before starting a new one + if (state.cancelToken != null) { + await stopForegroundBackup(); + } + state = state.copyWith(error: BackupError.none); final cancelToken = CancellationToken(); @@ -375,21 +380,21 @@ class DriftBackupNotifier extends StateNotifier { _logger.warning("Skip handleBackupResume (pre-call): notifier disposed"); return; } - _logger.info("Resuming backup tasks..."); + _logger.info("Start background backup sequence"); state = state.copyWith(error: BackupError.none); final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup); if (!mounted) { _logger.warning("Skip handleBackupResume (post-call): notifier disposed"); return; } - _logger.info("Found ${tasks.length} tasks"); + _logger.info("Found ${tasks.length} pending tasks"); if (tasks.isEmpty) { - _logger.info("Start backup with URLSession"); + _logger.info("No pending tasks, starting new upload"); return _backgroundUploadService.uploadBackupCandidates(userId); } - _logger.info("Tasks to resume: ${tasks.length}"); + _logger.info("Resuming upload ${tasks.length} assets"); return _backgroundUploadService.resume(); } } diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart deleted file mode 100644 index b9e09eb357..0000000000 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:ui' as ui; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:logging/logging.dart'; -import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; - -/// The local image provider for an asset -class ImmichLocalImageProvider extends ImageProvider { - final Asset asset; - // only used for videos - final double width; - final double height; - final Logger log = Logger('ImmichLocalImageProvider'); - - ImmichLocalImageProvider({required this.asset, required this.width, required this.height}) - : assert(asset.local != null, 'Only usable when asset.local is set'); - - /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key - /// that describes the precise image to load. - @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } - - @override - ImageStreamCompleter loadImage(ImmichLocalImageProvider key, ImageDecoderCallback decode) { - final chunkEvents = StreamController(); - return MultiImageStreamCompleter( - codec: _codec(key.asset, decode, chunkEvents), - scale: 1.0, - chunkEvents: chunkEvents.stream, - informationCollector: () sync* { - yield ErrorDescription(asset.fileName); - }, - ); - } - - // Streams in each stage of the image as we ask for it - Stream _codec( - Asset asset, - ImageDecoderCallback decode, - StreamController chunkEvents, - ) async* { - try { - final local = asset.local; - if (local == null) { - throw StateError('Asset ${asset.fileName} has no local data'); - } - - switch (asset.type) { - case AssetType.image: - final File? file = await local.originFile; - if (file == null) { - throw StateError("Opening file for asset ${asset.fileName} failed"); - } - final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); - yield await decode(buffer); - break; - case AssetType.video: - final size = ThumbnailSize(width.ceil(), height.ceil()); - final thumbBytes = await local.thumbnailDataWithSize(size); - if (thumbBytes == null) { - throw StateError("Failed to load preview for ${asset.fileName}"); - } - final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); - yield await decode(buffer); - break; - default: - throw StateError('Unsupported asset type ${asset.type}'); - } - } catch (error, stack) { - log.severe('Error loading local image ${asset.fileName}', error, stack); - } finally { - unawaited(chunkEvents.close()); - } - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is ImmichLocalImageProvider) { - return asset.id == other.asset.id && asset.localId == other.asset.localId; - } - return false; - } - - @override - int get hashCode => Object.hash(asset.id, asset.localId); -} diff --git a/mobile/lib/providers/image/immich_local_thumbnail_provider.dart b/mobile/lib/providers/image/immich_local_thumbnail_provider.dart deleted file mode 100644 index 5edb0fc79e..0000000000 --- a/mobile/lib/providers/image/immich_local_thumbnail_provider.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; -import 'package:logging/logging.dart'; - -/// The local image provider for an asset -/// Only viable -class ImmichLocalThumbnailProvider extends ImageProvider { - final Asset asset; - final int height; - final int width; - final CacheManager? cacheManager; - final Logger log = Logger("ImmichLocalThumbnailProvider"); - final String? userId; - - ImmichLocalThumbnailProvider({ - required this.asset, - this.height = 256, - this.width = 256, - this.cacheManager, - this.userId, - }) : assert(asset.local != null, 'Only usable when asset.local is set'); - - /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key - /// that describes the precise image to load. - @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } - - @override - ImageStreamCompleter loadImage(ImmichLocalThumbnailProvider key, ImageDecoderCallback decode) { - final cache = cacheManager ?? ThumbnailImageCacheManager(); - return MultiImageStreamCompleter( - codec: _codec(key.asset, cache, decode), - scale: 1.0, - informationCollector: () sync* { - yield ErrorDescription(key.asset.fileName); - }, - ); - } - - // Streams in each stage of the image as we ask for it - Stream _codec(Asset assetData, CacheManager cache, ImageDecoderCallback decode) async* { - final cacheKey = '$userId${assetData.localId}${assetData.checksum}$width$height'; - final fileFromCache = await cache.getFileFromCache(cacheKey); - if (fileFromCache != null) { - try { - final buffer = await ui.ImmutableBuffer.fromFilePath(fileFromCache.file.path); - final codec = await decode(buffer); - yield codec; - return; - } catch (error) { - log.severe('Found thumbnail in cache, but loading it failed', error); - } - } - - final thumbnailBytes = await assetData.local?.thumbnailDataWithSize(ThumbnailSize(width, height), quality: 80); - if (thumbnailBytes == null) { - throw StateError("Loading thumb for local photo ${assetData.fileName} failed"); - } - - final buffer = await ui.ImmutableBuffer.fromUint8List(thumbnailBytes); - final codec = await decode(buffer); - yield codec; - await cache.putFile(cacheKey, thumbnailBytes); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is ImmichLocalThumbnailProvider) { - return asset.id == other.asset.id && asset.localId == other.asset.localId; - } - return false; - } - - @override - int get hashCode => Object.hash(asset.id, asset.localId); -} diff --git a/mobile/lib/providers/image/immich_remote_image_provider.dart b/mobile/lib/providers/image/immich_remote_image_provider.dart deleted file mode 100644 index 16d5312e4c..0000000000 --- a/mobile/lib/providers/image/immich_remote_image_provider.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/providers/image/cache/image_loader.dart'; -import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; -import 'package:openapi/api.dart' as api; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; - -/// The remote image provider for full size remote images -class ImmichRemoteImageProvider extends ImageProvider { - /// The [Asset.remoteId] of the asset to fetch - final String assetId; - - /// The image cache manager - final CacheManager? cacheManager; - - const ImmichRemoteImageProvider({required this.assetId, this.cacheManager}); - - /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key - /// that describes the precise image to load. - @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } - - @override - ImageStreamCompleter loadImage(ImmichRemoteImageProvider key, ImageDecoderCallback decode) { - final cache = cacheManager ?? RemoteImageCacheManager(); - final chunkEvents = StreamController(); - return MultiImageStreamCompleter( - codec: _codec(key, cache, decode, chunkEvents), - scale: 1.0, - chunkEvents: chunkEvents.stream, - ); - } - - /// Whether to show the original file or load a compressed version - bool get _useOriginal => Store.get(AppSettingsEnum.loadOriginal.storeKey, AppSettingsEnum.loadOriginal.defaultValue); - - // Streams in each stage of the image as we ask for it - Stream _codec( - ImmichRemoteImageProvider key, - CacheManager cache, - ImageDecoderCallback decode, - StreamController chunkEvents, - ) async* { - // Load the higher resolution version of the image - final url = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.preview); - final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents); - yield codec; - - // Load the final remote image - if (_useOriginal) { - // Load the original image - final url = getOriginalUrlForRemoteId(key.assetId); - final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents); - yield codec; - } - await chunkEvents.close(); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is ImmichRemoteImageProvider) { - return assetId == other.assetId; - } - - return false; - } - - @override - int get hashCode => assetId.hashCode; -} diff --git a/mobile/lib/providers/image/immich_remote_thumbnail_provider.dart b/mobile/lib/providers/image/immich_remote_thumbnail_provider.dart deleted file mode 100644 index 08ee4325e8..0000000000 --- a/mobile/lib/providers/image/immich_remote_thumbnail_provider.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/providers/image/cache/image_loader.dart'; -import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; -import 'package:openapi/api.dart' as api; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; - -/// The remote image provider -class ImmichRemoteThumbnailProvider extends ImageProvider { - /// The [Asset.remoteId] of the asset to fetch - final String assetId; - - final int? height; - final int? width; - - /// The image cache manager - final CacheManager? cacheManager; - - const ImmichRemoteThumbnailProvider({required this.assetId, this.height, this.width, this.cacheManager}); - - /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key - /// that describes the precise image to load. - @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } - - @override - ImageStreamCompleter loadImage(ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode) { - final cache = cacheManager ?? ThumbnailImageCacheManager(); - return MultiImageStreamCompleter(codec: _codec(key, cache, decode), scale: 1.0); - } - - // Streams in each stage of the image as we ask for it - Stream _codec(ImmichRemoteThumbnailProvider key, CacheManager cache, ImageDecoderCallback decode) async* { - // Load a preview to the chunk events - final preview = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.thumbnail); - - yield await ImageLoader.loadImageFromCache(preview, cache: cache, decode: decode); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is ImmichRemoteThumbnailProvider) { - return assetId == other.assetId; - } - - return false; - } - - @override - int get hashCode => assetId.hashCode; -} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 924e9c558a..75f40ca290 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -405,11 +405,15 @@ class ActionNotifier extends Notifier { } } - Future shareAssets(ActionSource source, BuildContext context) async { + Future shareAssets( + ActionSource source, + BuildContext context, { + Completer? cancelCompleter, + }) async { final ids = _getAssets(source).toList(growable: false); try { - await _service.shareAssets(ids, context); + await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter); return ActionResult(count: ids.length, success: true); } catch (error, stack) { _logger.severe('Failed to share assets', error, stack); diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 60300e74df..01d0f61d1c 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/local_image_api.g.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/remote_image_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); @@ -20,3 +21,5 @@ final connectivityApiProvider = Provider((_) => ConnectivityApi final localImageApi = LocalImageApi(); final remoteImageApi = RemoteImageApi(); + +final networkApi = NetworkApi(); diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index bb201a607c..fba4fa7294 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -15,7 +15,7 @@ class ServerInfoNotifier extends StateNotifier { : super( const ServerInfo( serverVersion: ServerVersion(major: 0, minor: 0, patch: 0), - latestVersion: ServerVersion(major: 0, minor: 0, patch: 0), + latestVersion: null, serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true), serverConfig: ServerConfig( trashDays: 30, @@ -43,7 +43,7 @@ class ServerInfoNotifier extends StateNotifier { try { final serverVersion = await _serverInfoService.getServerVersion(); - // using isClientOutOfDate since that will show to users reguardless of if they are an admin + // using isClientOutOfDate since that will show to users regardless of if they are an admin if (serverVersion == null) { state = state.copyWith(versionStatus: VersionStatus.error); return; @@ -76,7 +76,7 @@ class ServerInfoNotifier extends StateNotifier { state = state.copyWith(versionStatus: VersionStatus.upToDate); } - handleReleaseInfo(ServerVersion serverVersion, ServerVersion latestVersion) { + handleReleaseInfo(ServerVersion serverVersion, ServerVersion? latestVersion) { // Update local server version _checkServerVersionMismatch(serverVersion, latestVersion: latestVersion); } diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 22fa3bdd07..fecfe6df4d 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -23,7 +23,6 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref. class AssetMediaRepository { final AssetApiRepository _assetApiRepository; - static final Logger _log = Logger("AssetMediaRepository"); const AssetMediaRepository(this._assetApiRepository); @@ -58,6 +57,7 @@ class AssetMediaRepository { static asset_entity.Asset? toAsset(AssetEntity? local) { if (local == null) return null; + final asset_entity.Asset asset = asset_entity.Asset( checksum: "", localId: local.id, @@ -72,19 +72,21 @@ class AssetMediaRepository { height: local.height, isFavorite: local.isFavorite, ); + if (asset.fileCreatedAt.year == 1970) { asset.fileCreatedAt = asset.fileModifiedAt; } + if (local.latitude != null) { asset.exifInfo = ExifInfo(latitude: local.latitude, longitude: local.longitude); } + asset.local = local; return asset; } Future getOriginalFilename(String id) async { final entity = await AssetEntity.fromId(id); - if (entity == null) { return null; } @@ -101,12 +103,31 @@ class AssetMediaRepository { } } + /// Deletes temporary files in parallel + Future _cleanupTempFiles(List tempFiles) async { + await Future.wait( + tempFiles.map((file) async { + try { + await file.delete(); + } catch (e) { + _log.warning("Failed to delete temporary file: ${file.path}", e); + } + }), + ); + } + // TODO: make this more efficient - Future shareAssets(List assets, BuildContext context) async { + Future shareAssets(List assets, BuildContext context, {Completer? cancelCompleter}) async { final downloadedXFiles = []; final tempFiles = []; for (var asset in assets) { + if (cancelCompleter != null && cancelCompleter.isCompleted) { + // if cancelled, delete any temp files created so far + await _cleanupTempFiles(tempFiles); + return 0; + } + final localId = (asset is LocalAsset) ? asset.id : asset is RemoteAsset @@ -146,6 +167,11 @@ class AssetMediaRepository { return 0; } + if (cancelCompleter != null && cancelCompleter.isCompleted) { + await _cleanupTempFiles(tempFiles); + return 0; + } + // we dont want to await the share result since the // "preparing" dialog will not disappear until final size = context.sizeData; @@ -154,13 +180,7 @@ class AssetMediaRepository { downloadedXFiles, sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)), ).then((result) async { - for (var file in tempFiles) { - try { - await file.delete(); - } catch (e) { - _log.warning("Failed to delete temporary file: ${file.path}", e); - } - } + await _cleanupTempFiles(tempFiles); }), ); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 13e491f321..3d3ef1494c 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -232,8 +232,8 @@ class ActionService { await _assetApiRepository.unStack(stackIds); } - Future shareAssets(List assets, BuildContext context) { - return _assetMediaRepository.shareAssets(assets, context); + Future shareAssets(List assets, BuildContext context, {Completer? cancelCompleter}) { + return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter); } Future> downloadAll(List assets) { diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index 4eece142d2..d54a677c24 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -164,9 +164,12 @@ class BackgroundUploadService { final candidates = await _backupRepository.getCandidates(userId); if (candidates.isEmpty) { + _logger.info("No new backup candidates found, finishing background upload"); return; } + _logger.info("Found ${candidates.length} backup candidates for background tasks"); + const batchSize = 100; final batch = candidates.take(batchSize).toList(); List tasks = []; @@ -179,6 +182,7 @@ class BackgroundUploadService { } if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { + _logger.info("Enqueuing ${tasks.length} background upload tasks"); await enqueueTasks(tasks); } } diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 25ca64e8c3..84a9ab52e1 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -27,17 +27,19 @@ import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; void configureFileDownloaderNotifications() { + final fileName = 'file_name'.t(args: {'file_name': '{filename}'}); + FileDownloader().configureNotificationForGroup( kDownloadGroupImage, - running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'), - complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'), + running: TaskNotification('downloading_media'.t(), fileName), + complete: TaskNotification('download_finished'.t(), fileName), progressBar: true, ); FileDownloader().configureNotificationForGroup( kDownloadGroupVideo, - running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'), - complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'), + running: TaskNotification('downloading_media'.t(), fileName), + complete: TaskNotification('download_finished'.t(), fileName), progressBar: true, ); diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index a3905baf9b..99ce0db57c 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -2,10 +2,6 @@ import 'package:flutter/painting.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; -import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; -import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; /// [ImageCache] that uses two caches for small and large images /// so that a single large image does not evict all small images @@ -39,14 +35,9 @@ final class CustomImageCache implements ImageCache { } /// Gets the cache for the given key - /// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] - /// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider] ImageCache _cacheForKey(Object key) { return switch (key) { - ImmichLocalImageProvider() || - ImmichRemoteImageProvider() || - LocalFullImageProvider() || - RemoteFullImageProvider() => _large, + LocalFullImageProvider() || RemoteFullImageProvider() => _large, ThumbHashProvider() => _thumbhash, _ => _small, }; diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart index c4e2ad69f7..a93387c9db 100644 --- a/mobile/lib/utils/http_ssl_options.dart +++ b/mobile/lib/utils/http_ssl_options.dart @@ -1,26 +1,20 @@ import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:logging/logging.dart'; class HttpSSLOptions { - static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions'); - - static void apply({bool applyNative = true}) { + static void apply() { AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - _apply(allowSelfSignedSSLCert, applyNative: applyNative); + return _apply(allowSelfSignedSSLCert); } - static void applyFromSettings(bool newValue) { - _apply(newValue); - } + static void applyFromSettings(bool newValue) => _apply(newValue); - static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) { + static void _apply(bool allowSelfSignedSSLCert) { String? serverHost; if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) { serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; @@ -29,14 +23,5 @@ class HttpSSLOptions { SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load(); HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); - - if (applyNative && Platform.isAndroid) { - _channel - .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password]) - .onError((e, _) { - final log = Logger("HttpSSLOptions"); - log.severe('Failed to set SSL options', e.message); - }); - } } } diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 491e1bf107..7ac120acb4 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -54,7 +54,7 @@ Cancelable runInIsolateGentle({ Logger log = Logger("IsolateLogger"); try { - HttpSSLOptions.apply(applyNative: false); + HttpSSLOptions.apply(); result = await computation(ref); } on CanceledError { log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 94ae69321f..70f9ba88c7 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -23,6 +23,8 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -32,7 +34,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 20; +const int targetVersion = 21; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -91,6 +93,13 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await _syncLocalAlbumIsIosSharedAlbum(drift); } + if (version < 21) { + final certData = SSLClientCertStoreVal.load(); + if (certData != null) { + await networkApi.addCertificate(ClientCertData(data: certData.data, password: certData.password ?? "")); + } + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; diff --git a/mobile/lib/widgets/activities/activity_tile.dart b/mobile/lib/widgets/activities/activity_tile.dart index 76c0b7bf2a..e0eccbff21 100644 --- a/mobile/lib/widgets/activities/activity_tile.dart +++ b/mobile/lib/widgets/activities/activity_tile.dart @@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @@ -102,7 +102,7 @@ class _ActivityAssetThumbnail extends StatelessWidget { decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), image: DecorationImage( - image: ImmichRemoteThumbnailProvider(assetId: assetId), + image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: ""), fit: BoxFit.cover, ), ), diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart index 3dd46cd92a..5f060833a7 100644 --- a/mobile/lib/widgets/activities/comment_bubble.dart +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; @@ -56,7 +56,7 @@ class CommentBubble extends ConsumerWidget { child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(10)), child: Image( - image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!), + image: RemoteImageProvider.thumbnail(assetId: activity.assetId!, thumbhash: ""), fit: BoxFit.cover, ), ), diff --git a/mobile/lib/widgets/album/album_thumbnail_listtile.dart b/mobile/lib/widgets/album/album_thumbnail_listtile.dart index 423410eedf..386084b034 100644 --- a/mobile/lib/widgets/album/album_thumbnail_listtile.dart +++ b/mobile/lib/widgets/album/album_thumbnail_listtile.dart @@ -1,12 +1,12 @@ import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; @@ -32,15 +32,12 @@ class AlbumThumbnailListTile extends StatelessWidget { } buildAlbumThumbnail() { - return CachedNetworkImage( + return SizedBox( width: cardSize, height: cardSize, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 200), - imageUrl: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail), - httpHeaders: ApiService.getRequestHeaders(), - cacheKey: getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail), - errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined), + child: Thumbnail( + imageProvider: RemoteImageProvider(url: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail)), + ), ); } diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart index a83a3beee3..a341d6395c 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart @@ -170,50 +170,52 @@ class AppBarServerInfo extends HookConsumerWidget { ), ], ), - const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Row( - children: [ - if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate) - const Padding( - padding: EdgeInsets.only(right: 5.0), - child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12), + if (serverInfoState.latestVersion != null) ...[ + const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Row( + children: [ + if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate) + const Padding( + padding: EdgeInsets.only(right: 5.0), + child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12), + ), + Text( + "latest_version".tr(), + style: TextStyle( + fontSize: titleFontSize, + color: context.textTheme.labelSmall?.color, + fontWeight: FontWeight.w500, + ), ), - Text( - "latest_version".tr(), - style: TextStyle( - fontSize: titleFontSize, - color: context.textTheme.labelSmall?.color, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - Expanded( - flex: 0, - child: Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Text( - serverInfoState.latestVersion.major > 0 - ? "${serverInfoState.latestVersion.major}.${serverInfoState.latestVersion.minor}.${serverInfoState.latestVersion.patch}" - : "--", - style: TextStyle( - fontSize: contentFontSize, - color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, + ], ), ), ), - ), - ], - ), + Expanded( + flex: 0, + child: Padding( + padding: const EdgeInsets.only(right: 10.0), + child: Text( + serverInfoState.latestVersion!.major > 0 + ? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}" + : "--", + style: TextStyle( + fontSize: contentFontSize, + color: context.colorScheme.onSurfaceSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ], ], ), ), diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index c8bc9c1f6a..141a2ac7d4 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:octo_image/octo_image.dart'; @@ -34,13 +35,21 @@ class ImmichImage extends StatelessWidget { } if (asset == null) { - return ImmichRemoteImageProvider(assetId: assetId!); + return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video); } if (useLocal(asset)) { - return ImmichLocalImageProvider(asset: asset, width: width, height: height); + return LocalFullImageProvider( + id: asset.localId!, + assetType: base_asset.AssetType.video, + size: Size(width, height), + ); } else { - return ImmichRemoteImageProvider(assetId: asset.remoteId!); + return RemoteFullImageProvider( + assetId: asset.remoteId!, + thumbhash: asset.thumbhash ?? '', + assetType: base_asset.AssetType.video, + ); } } diff --git a/mobile/lib/widgets/common/immich_thumbnail.dart b/mobile/lib/widgets/common/immich_thumbnail.dart index 612a6a4bd0..f17353c3aa 100644 --- a/mobile/lib/widgets/common/immich_thumbnail.dart +++ b/mobile/lib/widgets/common/immich_thumbnail.dart @@ -2,15 +2,15 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; import 'package:immich_mobile/utils/thumbnail_utils.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart'; import 'package:octo_image/octo_image.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset; class ImmichThumbnail extends HookConsumerWidget { const ImmichThumbnail({this.asset, this.width = 250, this.height = 250, this.fit = BoxFit.cover, super.key}); @@ -24,26 +24,29 @@ class ImmichThumbnail extends HookConsumerWidget { /// either by using the asset ID or the asset itself /// [asset] is the Asset to request, or else use [assetId] to get a remote /// image provider - static ImageProvider imageProvider({Asset? asset, String? assetId, String? userId, int thumbnailSize = 256}) { + static ImageProvider imageProvider({Asset? asset, String? assetId, int thumbnailSize = 256}) { if (asset == null && assetId == null) { throw Exception('Must supply either asset or assetId'); } if (asset == null) { - return ImmichRemoteThumbnailProvider(assetId: assetId!); + return RemoteImageProvider.thumbnail(assetId: assetId!, thumbhash: ""); } if (ImmichImage.useLocal(asset)) { - return ImmichLocalThumbnailProvider(asset: asset, height: thumbnailSize, width: thumbnailSize, userId: userId); + return LocalThumbProvider( + id: asset.localId!, + assetType: base_asset.AssetType.video, + size: Size(thumbnailSize.toDouble(), thumbnailSize.toDouble()), + ); } else { - return ImmichRemoteThumbnailProvider(assetId: asset.remoteId!, height: thumbnailSize, width: thumbnailSize); + return RemoteImageProvider.thumbnail(assetId: asset.remoteId!, thumbhash: asset.thumbhash ?? ""); } } @override Widget build(BuildContext context, WidgetRef ref) { Uint8List? blurhash = useBlurHashRef(asset).value; - final userId = ref.watch(currentUserProvider)?.id; if (asset == null) { return Container( @@ -56,7 +59,7 @@ class ImmichThumbnail extends HookConsumerWidget { final assetAltText = getAltText(asset!.exifInfo, asset!.fileCreatedAt, asset!.type, []); - final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset, userId: userId); + final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset); customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) { thumbnailProviderInstance.evict(); diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index d5a7ea7cd9..a2a9d1bdbd 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -14,8 +14,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/people.utils.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; @@ -230,10 +230,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S elevation: 3, child: CircleAvatar( maxRadius: 84 / 2, - backgroundImage: NetworkImage( - getFaceThumbnailUrl(widget.person.id), - headers: ApiService.getRequestHeaders(), - ), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(widget.person.id)), ), ), ), diff --git a/mobile/lib/widgets/common/user_avatar.dart b/mobile/lib/widgets/common/user_avatar.dart index ff0e39f371..911d6a9f10 100644 --- a/mobile/lib/widgets/common/user_avatar.dart +++ b/mobile/lib/widgets/common/user_avatar.dart @@ -1,10 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; Widget userAvatar(BuildContext context, UserDto u, {double? radius}) { final url = "${Store.get(StoreKey.serverEndpoint)}/users/${u.id}/profile-image"; @@ -12,11 +11,7 @@ Widget userAvatar(BuildContext context, UserDto u, {double? radius}) { return CircleAvatar( radius: radius, backgroundColor: context.primaryColor.withAlpha(50), - foregroundImage: CachedNetworkImageProvider( - url, - headers: ApiService.getRequestHeaders(), - cacheKey: "user-${u.id}-profile", - ), + foregroundImage: RemoteImageProvider(url: url), // silence errors if user has no profile image, use initials as fallback onForegroundImageError: (exception, stackTrace) {}, child: Text(nameFirstLetter.toUpperCase()), diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index b46f560122..0e6d6761e3 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -1,13 +1,11 @@ import 'dart:math'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/widgets/common/transparent_image.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; // ignore: must_be_immutable class UserCircleAvatar extends ConsumerWidget { @@ -46,16 +44,12 @@ class UserCircleAvatar extends ConsumerWidget { child: user.hasProfileImage ? ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(50)), - child: CachedNetworkImage( + child: Image( fit: BoxFit.cover, - cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}', width: size, height: size, - placeholder: (_, __) => Image.memory(kTransparentImage), - imageUrl: profileImageUrl, - httpHeaders: ApiService.getRequestHeaders(), - fadeInDuration: const Duration(milliseconds: 300), - errorWidget: (context, error, stackTrace) => textIcon, + image: RemoteImageProvider(url: profileImageUrl), + errorBuilder: (context, error, stackTrace) => textIcon, ), ) : textIcon, diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 71086fd803..2aa770f104 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -414,6 +414,7 @@ class LoginForm extends HookConsumerWidget { keyboardAction: TextInputAction.next, keyboardType: TextInputType.url, autofillHints: const [AutofillHints.url], + autoCorrect: false, onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(), ), ), diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index becef728da..95b127f5b7 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -1,10 +1,9 @@ import 'dart:io'; import 'dart:math'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { @@ -53,7 +52,6 @@ class _AssetMarkerIcon extends StatelessWidget { @override Widget build(BuildContext context) { final imageUrl = getThumbnailUrlForRemoteId(id); - final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash); return LayoutBuilder( builder: (context, constraints) { return Stack( @@ -79,12 +77,7 @@ class _AssetMarkerIcon extends StatelessWidget { backgroundColor: context.colorScheme.onSurface, child: CircleAvatar( radius: constraints.maxHeight * 0.37, - backgroundImage: CachedNetworkImageProvider( - imageUrl, - cacheKey: cacheKey, - headers: ApiService.getRequestHeaders(), - errorListener: (_) => const Icon(Icons.image_not_supported_outlined), - ), + backgroundImage: RemoteImageProvider(url: imageUrl), ), ), ), diff --git a/mobile/lib/widgets/search/curated_people_row.dart b/mobile/lib/widgets/search/curated_people_row.dart index 74fc3e1c34..9155de2131 100644 --- a/mobile/lib/widgets/search/curated_people_row.dart +++ b/mobile/lib/widgets/search/curated_people_row.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; class CuratedPeopleRow extends StatelessWidget { @@ -29,7 +29,6 @@ class CuratedPeopleRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: List.generate(content.length, (index) { final person = content[index]; - final headers = ApiService.getRequestHeaders(); return Padding( padding: const EdgeInsets.only(right: 16.0), child: Column( @@ -44,7 +43,7 @@ class CuratedPeopleRow extends StatelessWidget { elevation: 3, child: CircleAvatar( maxRadius: imageSize / 2, - backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)), ), ), ), diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index b2a7a18c7c..978b70239c 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -23,7 +23,6 @@ class PeoplePicker extends HookConsumerWidget { final imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); - final headers = ApiService.getRequestHeaders(); final selectedPeople = useState>(filter ?? {}); return Column( @@ -75,7 +74,7 @@ class PeoplePicker extends HookConsumerWidget { elevation: 3, child: CircleAvatar( maxRadius: imageSize / 2, - backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)), ), ), ), diff --git a/mobile/lib/widgets/search/thumbnail_with_info.dart b/mobile/lib/widgets/search/thumbnail_with_info.dart index af9460f929..7ba8257c8a 100644 --- a/mobile/lib/widgets/search/thumbnail_with_info.dart +++ b/mobile/lib/widgets/search/thumbnail_with_info.dart @@ -1,8 +1,8 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart'; -import 'package:immich_mobile/services/api.service.dart'; class ThumbnailWithInfo extends StatelessWidget { const ThumbnailWithInfo({ @@ -30,14 +30,7 @@ class ThumbnailWithInfo extends StatelessWidget { child: imageUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(borderRadius), - child: CachedNetworkImage( - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - imageUrl: imageUrl!, - httpHeaders: ApiService.getRequestHeaders(), - errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined), - ), + child: Thumbnail(imageProvider: RemoteImageProvider(url: imageUrl!)), ) : Center(child: Icon(noImageIcon ?? Icons.not_listed_location, color: textAndIconColor)), ); diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index dc31acf0a4..fa210ee720 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -1,14 +1,13 @@ -import 'dart:io'; - import 'package:easy_localization/easy_localization.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; +import 'package:logging/logging.dart'; class SslClientCertSettings extends StatefulWidget { const SslClientCertSettings({super.key, required this.isLoggedIn}); @@ -20,10 +19,12 @@ class SslClientCertSettings extends StatefulWidget { } class _SslClientCertSettingsState extends State { - _SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; + final _log = Logger("SslClientCertSettings"); bool isCertExist; + _SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; + @override Widget build(BuildContext context) { return ListTile( @@ -41,16 +42,12 @@ class _SslClientCertSettingsState extends State { const SizedBox(height: 6), Row( mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ + ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), ElevatedButton( - onPressed: widget.isLoggedIn ? null : () => importCert(context), - child: Text("client_cert_import".tr()), - ), - const SizedBox(width: 15), - ElevatedButton( - onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context), + onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert, child: Text("remove".tr()), ), ], @@ -60,71 +57,52 @@ class _SslClientCertSettingsState extends State { ); } - void showMessage(BuildContext context, String message) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - content: Text(message), - actions: [TextButton(onPressed: () => ctx.pop(), child: Text("client_cert_dialog_msg_confirm".tr()))], + void showMessage(String message) { + context.showSnackBar( + SnackBar( + duration: const Duration(seconds: 3), + content: Text(message, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor)), ), ); } - Future storeCert(BuildContext context, Uint8List data, String? password) async { - if (password != null && password.isEmpty) { - password = null; - } - final cert = SSLClientCertStoreVal(data, password); - // Test whether the certificate is valid - final isCertValid = HttpSSLCertOverride.setClientCert(SecurityContext(withTrustedRoots: true), cert); - if (!isCertValid) { - showMessage(context, "client_cert_invalid_msg".tr()); - return; - } - await cert.save(); - HttpSSLOptions.apply(); - setState(() => isCertExist = true); - showMessage(context, "client_cert_import_success_msg".tr()); - } - - void setPassword(BuildContext context, Uint8List data) { - final password = TextEditingController(); - showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - content: TextField( - controller: password, - obscureText: true, - obscuringCharacter: "*", - decoration: InputDecoration(hintText: "client_cert_enter_password".tr()), - ), - actions: [ - TextButton( - onPressed: () async => {ctx.pop(), await storeCert(context, data, password.text)}, - child: Text("client_cert_dialog_msg_confirm".tr()), - ), - ], - ), - ); - } - - Future importCert(BuildContext ctx) async { - FilePickerResult? res = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['p12', 'pfx'], - ); - if (res != null) { - File file = File(res.files.single.path!); - final bytes = await file.readAsBytes(); - setPassword(ctx, bytes); + Future importCert() async { + try { + final styling = ClientCertPrompt( + title: "client_cert_password_title".tr(), + message: "client_cert_password_message".tr(), + cancel: "cancel".tr(), + confirm: "confirm".tr(), + ); + final cert = await networkApi.selectCertificate(styling); + await SSLClientCertStoreVal(cert.data, cert.password).save(); + HttpSSLOptions.apply(); + setState(() => isCertExist = true); + showMessage("client_cert_import_success_msg".tr()); + } catch (e) { + if (_isCancellation(e)) { + return; + } + _log.severe("Error importing client cert", e); + showMessage("client_cert_invalid_msg".tr()); } } - Future removeCert(BuildContext context) async { - await SSLClientCertStoreVal.delete(); - HttpSSLOptions.apply(); - setState(() => isCertExist = false); - showMessage(context, "client_cert_remove_msg".tr()); + Future removeCert() async { + try { + await networkApi.removeCertificate(); + await SSLClientCertStoreVal.delete(); + HttpSSLOptions.apply(); + setState(() => isCertExist = false); + showMessage("client_cert_remove_msg".tr()); + } catch (e) { + if (_isCancellation(e)) { + return; + } + _log.severe("Error removing client cert", e); + showMessage("client_cert_invalid_msg".tr()); + } } + + bool _isCancellation(Object e) => e is PlatformException && e.code.toLowerCase().contains("cancel"); } diff --git a/mobile/makefile b/mobile/makefile index 79b263c079..5f0a1a9f05 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -12,12 +12,14 @@ pigeon: dart run pigeon --input pigeon/background_worker_api.dart dart run pigeon --input pigeon/background_worker_lock_api.dart dart run pigeon --input pigeon/connectivity_api.dart + dart run pigeon --input pigeon/network_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/local_image_api.g.dart dart format lib/platform/remote_image_api.g.dart dart format lib/platform/background_worker_api.g.dart dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart + dart format lib/platform/network_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs @@ -35,7 +37,7 @@ migration: dart run drift_dev make-migrations translation: - npm --prefix ../i18n run format:fix + pnpm --prefix ../i18n run format:fix dart run easy_localization:generate -S ../i18n dart run bin/generate_keys.dart dart format lib/generated/codegen_loader.g.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5ca810fe48..80c1a1c868 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 2.5.2 +- API version: 2.5.5 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index 713bcafee3..e2db95b9e0 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -473,7 +473,7 @@ class AlbumsApi { /// Filter albums containing this asset ID (ignores shared parameter) /// /// * [bool] shared: - /// Filter by shared status: true = only shared, false = only own, undefined = all + /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums Future getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async { // ignore: prefer_const_declarations final apiPath = r'/albums'; @@ -516,7 +516,7 @@ class AlbumsApi { /// Filter albums containing this asset ID (ignores shared parameter) /// /// * [bool] shared: - /// Filter by shared status: true = only shared, false = only own, undefined = all + /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums Future?> getAllAlbums({ String? assetId, bool? shared, }) async { final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, ); if (response.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index c770265860..a373743852 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -53,7 +53,7 @@ class AssetBulkUpdateDto { /// String? description; - /// Duplicate asset ID + /// Duplicate ID String? duplicateId; /// Asset IDs to update diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index 320d318062..485b2e00e5 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -19,6 +19,7 @@ class UserAdminCreateDto { required this.name, this.notify, required this.password, + this.pinCode, this.quotaSizeInBytes, this.shouldChangePassword, this.storageLabel, @@ -54,6 +55,9 @@ class UserAdminCreateDto { /// User password String password; + /// PIN code + String? pinCode; + /// Storage quota in bytes /// /// Minimum value: 0 @@ -79,6 +83,7 @@ class UserAdminCreateDto { other.name == name && other.notify == notify && other.password == password && + other.pinCode == pinCode && other.quotaSizeInBytes == quotaSizeInBytes && other.shouldChangePassword == shouldChangePassword && other.storageLabel == storageLabel; @@ -92,12 +97,13 @@ class UserAdminCreateDto { (name.hashCode) + (notify == null ? 0 : notify!.hashCode) + (password.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode) + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) + (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, notify=$notify, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; @@ -119,6 +125,11 @@ class UserAdminCreateDto { // json[r'notify'] = null; } json[r'password'] = this.password; + if (this.pinCode != null) { + json[r'pinCode'] = this.pinCode; + } else { + // json[r'pinCode'] = null; + } if (this.quotaSizeInBytes != null) { json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; } else { @@ -152,6 +163,7 @@ class UserAdminCreateDto { name: mapValueOfType(json, r'name')!, notify: mapValueOfType(json, r'notify'), password: mapValueOfType(json, r'password')!, + pinCode: mapValueOfType(json, r'pinCode'), quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword'), storageLabel: mapValueOfType(json, r'storageLabel'), diff --git a/mobile/packages/ui/lib/src/components/text_input.dart b/mobile/packages/ui/lib/src/components/text_input.dart index f335df49f4..1b3fb91f51 100644 --- a/mobile/packages/ui/lib/src/components/text_input.dart +++ b/mobile/packages/ui/lib/src/components/text_input.dart @@ -12,6 +12,7 @@ class ImmichTextInput extends StatefulWidget { final List? autofillHints; final Widget? suffixIcon; final bool obscureText; + final bool autoCorrect; const ImmichTextInput({ super.key, @@ -26,6 +27,7 @@ class ImmichTextInput extends StatefulWidget { this.autofillHints, this.suffixIcon, this.obscureText = false, + this.autoCorrect = true, }); @override @@ -79,6 +81,7 @@ class _ImmichTextInputState extends State { validator: _validateInput, keyboardType: widget.keyboardType, textInputAction: widget.keyboardAction, + autocorrect: widget.autoCorrect, autofillHints: widget.autofillHints, onTap: () => setState(() => _error = null), onTapOutside: (_) => _focusNode.unfocus(), diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart new file mode 100644 index 0000000000..68d2f7d8fc --- /dev/null +++ b/mobile/pigeon/network_api.dart @@ -0,0 +1,41 @@ +import 'package:pigeon/pigeon.dart'; + +class ClientCertData { + Uint8List data; + String password; + + ClientCertData(this.data, this.password); +} + +class ClientCertPrompt { + String title; + String message; + String cancel; + String confirm; + + ClientCertPrompt(this.title, this.message, this.cancel, this.confirm); +} + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/network_api.g.dart', + swiftOut: 'ios/Runner/Core/Network.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.core', includeErrorClass: true), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class NetworkApi { + @async + void addCertificate(ClientCertData clientData); + + @async + ClientCertData selectCertificate(ClientCertPrompt promptText); + + @async + void removeCertificate(); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d237c02023..077544b4f7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -201,30 +201,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.5" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" - url: "https://pub.dev" - source: hosted - version: "1.3.1" cancellation_token: dependency: transitive description: @@ -538,14 +514,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 - url: "https://pub.dev" - source: hosted - version: "8.3.7" file_selector_linux: dependency: transitive description: @@ -1249,10 +1217,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1942,10 +1910,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 198d3ad8f7..19b41f2799 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 2.5.2+3033 +version: 2.5.5+3036 environment: sdk: '>=3.8.0 <4.0.0' @@ -12,7 +12,6 @@ dependencies: async: ^2.13.0 auto_route: ^9.2.0 background_downloader: ^9.3.0 - cached_network_image: ^3.4.1 cancellation_token_http: ^2.1.0 cast: ^2.1.0 collection: ^1.19.1 @@ -26,7 +25,6 @@ dependencies: dynamic_color: ^1.8.1 easy_localization: ^3.0.8 ffi: ^2.1.4 - file_picker: ^8.0.0+1 flutter: sdk: flutter flutter_cache_manager: ^3.4.1 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7f85bbc1cf..901d85c02c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1618,7 +1618,7 @@ "name": "shared", "required": false, "in": "query", - "description": "Filter by shared status: true = only shared, false = only own, undefined = all", + "description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums", "schema": { "type": "boolean" } @@ -4959,7 +4959,22 @@ "summary": "Retrieve auth status", "tags": [ "Authentication" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/auth/validateToken": { @@ -15057,7 +15072,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "2.5.2", + "version": "2.5.5", "contact": {} }, "tags": [ @@ -15760,7 +15775,7 @@ "type": "string" }, "duplicateId": { - "description": "Duplicate asset ID", + "description": "Duplicate ID", "nullable": true, "type": "string" }, @@ -19038,6 +19053,7 @@ "format": "uuid", "type": "string" }, + "minItems": 1, "type": "array" } }, @@ -19128,6 +19144,7 @@ "format": "uuid", "type": "string" }, + "minItems": 1, "type": "array" }, "readAt": { @@ -25069,6 +25086,12 @@ "description": "User password", "type": "string" }, + "pinCode": { + "description": "PIN code", + "example": "123456", + "nullable": true, + "type": "string" + }, "quotaSizeInBytes": { "description": "Storage quota in bytes", "format": "int64", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 2d30f4fbd8..96f858e6d8 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "2.5.2", + "version": "2.5.5", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d8c960a393..e6c31aeeae 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 2.5.2 + * 2.5.5 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -233,6 +233,8 @@ export type UserAdminCreateDto = { notify?: boolean; /** User password */ password: string; + /** PIN code */ + pinCode?: string | null; /** Storage quota in bytes */ quotaSizeInBytes?: number | null; /** Require password change on next login */ @@ -822,7 +824,7 @@ export type AssetBulkUpdateDto = { dateTimeRelative?: number; /** Asset description */ description?: string; - /** Duplicate asset ID */ + /** Duplicate ID */ duplicateId?: string | null; /** Asset IDs to update */ ids: string[]; diff --git a/package.json b/package.json index dadfba8bf0..3e04703974 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "immich-monorepo", - "version": "2.5.2", + "version": "2.5.5", "description": "Monorepo for Immich", "private": true, "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9f7fcaf5..2030bbc08c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.59.0 - version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) + specifier: ^0.61.3 + version: 0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3126,13 +3126,13 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} - '@immich/svelte-markdown-preprocess@0.1.0': - resolution: {integrity: sha512-jgSOJEGLPKEXQCNRI4r4YUayeM2b0ZYLdzgKGl891jZBhOQIetlY7rU44kPpV1AA3/8wGDwNFKduIQZZ/qJYzg==} + '@immich/svelte-markdown-preprocess@0.2.1': + resolution: {integrity: sha512-mbr/g75lO8Zh+ELCuYrZP0XB4gf2UbK8rJcGYMYxFJJzMMunV+sm9FqtV1dbwW2dpXzCZGz1XPCEZ6oo526TbA==} peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.59.0': - resolution: {integrity: sha512-7yxvyhhd99T0AHhjMakp7c/U4n0jGAmRO5xpncsRASRvqZve/LAibjr6N5FJc5IAd222DROTMLn6imsxVfqfvg==} + '@immich/ui@0.61.3': + resolution: {integrity: sha512-9cz/7kc/CSmJ37gH5nIZNiHxw5OlBCGbdlSGkCOtaMJ458wmcdUFVmF5arjGioaOa4NMwseOVyln7rMhkNU7ww==} peerDependencies: svelte: ^5.0.0 @@ -7802,6 +7802,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + front-matter@4.0.2: + resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -9081,6 +9084,11 @@ packages: engines: {node: '>= 20'} hasBin: true + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -15747,13 +15755,16 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.48.0)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.48.0)': dependencies: + front-matter: 4.0.2 + marked: 17.0.1 + node-emoji: 2.2.0 svelte: 5.48.0 - '@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)': + '@immich/ui@0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)': dependencies: - '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.48.0) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.48.0) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) @@ -21100,6 +21111,10 @@ snapshots: fresh@2.0.0: {} + front-matter@4.0.2: + dependencies: + js-yaml: 3.14.2 + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -22570,6 +22585,8 @@ snapshots: marked@16.4.2: {} + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: diff --git a/server/bin/start.sh b/server/bin/start.sh index 0afff4c3a8..0a26be8e0b 100755 --- a/server/bin/start.sh +++ b/server/bin/start.sh @@ -1,5 +1,19 @@ #!/usr/bin/env bash -echo "Initializing Immich $IMMICH_SOURCE_REF" + +# Quiet mode suppresses informational output (enabled for immich-admin) +QUIET=false +if [ "$1" = "immich-admin" ]; then + QUIET=true +fi + +# Helper function that only logs when not in quiet mode +log_message() { + if [ "$QUIET" = "false" ]; then + echo "$@" + fi +} + +log_message "Initializing Immich $IMMICH_SOURCE_REF" # TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed # lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3" @@ -30,16 +44,20 @@ read_file_and_export "DB_PASSWORD_FILE" "DB_PASSWORD" read_file_and_export "REDIS_PASSWORD_FILE" "REDIS_PASSWORD" if CPU_CORES="${CPU_CORES:=$(get-cpus.sh 2>/dev/null)}"; then - echo "Detected CPU Cores: $CPU_CORES" + log_message "Detected CPU Cores: $CPU_CORES" if [ "$CPU_CORES" -gt 4 ]; then export UV_THREADPOOL_SIZE=$CPU_CORES fi else - echo "skipping get-cpus.sh - not found in PATH or failed: using default UV_THREADPOOL_SIZE" + log_message "skipping get-cpus.sh - not found in PATH or failed: using default UV_THREADPOOL_SIZE" fi if [ -f "${SERVER_HOME}/dist/main.js" ]; then - exec node "${SERVER_HOME}/dist/main.js" "$@" + if [ "$QUIET" = "true" ]; then + exec node --no-warnings "${SERVER_HOME}/dist/main.js" "$@" + else + exec node "${SERVER_HOME}/dist/main.js" "$@" + fi else echo "Error: ${SERVER_HOME}/dist/main.js not found" if [ "$IMMICH_ENV" = "development" ]; then diff --git a/server/package.json b/server/package.json index c9e2c2ac22..70ef426557 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "2.5.2", + "version": "2.5.5", "description": "", "author": "", "private": true, diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index cf8b80be38..197e06d02d 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -24,6 +24,34 @@ describe(AssetController.name, () => { await request(ctx.getHttpServer()).put(`/assets`); expect(ctx.authenticate).toHaveBeenCalled(); }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets`) + .send({ ids: ['123'] }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['each value in ids must be a UUID'])); + }); + + it('should require duplicateId to be a string', async () => { + const id = factory.uuid(); + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets`) + .send({ ids: [id], duplicateId: true }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['duplicateId must be a string'])); + }); + + it('should accept a null duplicateId', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()) + .put(`/assets`) + .send({ ids: [id], duplicateId: null }); + + expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ duplicateId: null })); + }); }); describe('DELETE /assets', () => { diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index ea09e33080..63cdce4f32 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -112,6 +112,7 @@ export class AuthController { summary: 'Retrieve auth status', description: 'Get information about the current session, including whether the user has a password, and if the session can access locked assets.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) getAuthStatus(@Auth() auth: AuthDto): Promise { return this.service.getAuthStatus(auth); diff --git a/server/src/controllers/notification-admin.controller.spec.ts b/server/src/controllers/notification-admin.controller.spec.ts new file mode 100644 index 0000000000..b93726eb32 --- /dev/null +++ b/server/src/controllers/notification-admin.controller.spec.ts @@ -0,0 +1,36 @@ +import { NotificationAdminController } from 'src/controllers/notification-admin.controller'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(NotificationAdminController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(NotificationAdminService); + + beforeAll(async () => { + ctx = await controllerSetup(NotificationAdminController, [ + { provide: NotificationAdminService, useValue: service }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /admin/notifications', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/admin/notifications'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should accept a null readAt', async () => { + await request(ctx.getHttpServer()) + .post(`/admin/notifications`) + .send({ title: 'Test', userId: factory.uuid(), readAt: null }); + expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ readAt: null })); + }); + }); +}); diff --git a/server/src/controllers/notification.controller.spec.ts b/server/src/controllers/notification.controller.spec.ts index 0dce7d73b5..a64aee2912 100644 --- a/server/src/controllers/notification.controller.spec.ts +++ b/server/src/controllers/notification.controller.spec.ts @@ -37,9 +37,33 @@ describe(NotificationController.name, () => { describe('PUT /notifications', () => { it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/notifications'); + await request(ctx.getHttpServer()).put('/notifications'); expect(ctx.authenticate).toHaveBeenCalled(); }); + + describe('ids', () => { + it('should require a list', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['ids must be an array']))); + }); + + it('should require uuids', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/notifications`) + .send({ ids: [true] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID'])); + }); + + it('should accept valid uuids', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()) + .put(`/notifications`) + .send({ ids: [id] }); + expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ ids: [id] })); + }); + }); }); describe('GET /notifications/:id', () => { @@ -60,5 +84,11 @@ describe(NotificationController.name, () => { await request(ctx.getHttpServer()).put(`/notifications/${factory.uuid()}`).send({ readAt: factory.date() }); expect(ctx.authenticate).toHaveBeenCalled(); }); + + it('should accept a null readAt', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).put(`/notifications/${id}`).send({ readAt: null }); + expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ readAt: null })); + }); }); }); diff --git a/server/src/controllers/person.controller.spec.ts b/server/src/controllers/person.controller.spec.ts index 5b63fcc6cd..a28ac9b659 100644 --- a/server/src/controllers/person.controller.spec.ts +++ b/server/src/controllers/person.controller.spec.ts @@ -58,6 +58,11 @@ describe(PersonController.name, () => { await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' }); expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null }); }); + + it('should map an empty color to null', async () => { + await request(ctx.getHttpServer()).post('/people').send({ color: '' }); + expect(service.create).toHaveBeenCalledWith(undefined, { color: null }); + }); }); describe('DELETE /people', () => { diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts new file mode 100644 index 0000000000..96c84040ca --- /dev/null +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -0,0 +1,34 @@ +import { SharedLinkController } from 'src/controllers/shared-link.controller'; +import { SharedLinkType } from 'src/enum'; +import { SharedLinkService } from 'src/services/shared-link.service'; +import request from 'supertest'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(SharedLinkController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(SharedLinkService); + + beforeAll(async () => { + ctx = await controllerSetup(SharedLinkController, [{ provide: SharedLinkService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /shared-links', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/shared-links'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should allow an null expiresAt', async () => { + await request(ctx.getHttpServer()) + .post('/shared-links') + .send({ expiresAt: null, type: SharedLinkType.Individual }); + expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); + }); + }); +}); diff --git a/server/src/controllers/tag.controller.spec.ts b/server/src/controllers/tag.controller.spec.ts new file mode 100644 index 0000000000..60fc3d65ae --- /dev/null +++ b/server/src/controllers/tag.controller.spec.ts @@ -0,0 +1,73 @@ +import { TagController } from 'src/controllers/tag.controller'; +import { TagService } from 'src/services/tag.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(TagController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(TagService); + + beforeAll(async () => { + ctx = await controllerSetup(TagController, [{ provide: TagService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /tags', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/tags'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('POST /tags', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/tags'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should a null parentId', async () => { + await request(ctx.getHttpServer()).post(`/tags`).send({ name: 'tag', parentId: null }); + expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ parentId: null })); + }); + }); + + describe('PUT /tags', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put('/tags'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /tags/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/tags/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')])); + }); + }); + + describe('PUT /tags/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should allow setting a null color via an empty string', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' }); + expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null })); + }); + }); +}); diff --git a/server/src/controllers/user-admin.controller.spec.ts b/server/src/controllers/user-admin.controller.spec.ts index bd9c966d42..edda974476 100644 --- a/server/src/controllers/user-admin.controller.spec.ts +++ b/server/src/controllers/user-admin.controller.spec.ts @@ -31,12 +31,55 @@ describe(UserAdminController.name, () => { }); }); + describe('PUT /admin/users/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + describe('POST /admin/users', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).post('/admin/users'); expect(ctx.authenticate).toHaveBeenCalled(); }); + it('should allow a null pinCode', async () => { + await request(ctx.getHttpServer()).post(`/admin/users`).send({ + name: 'Test user', + email: 'test@immich.cloud', + password: 'password', + pinCode: null, + }); + expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ pinCode: null })); + }); + + it('should allow a null avatarColor', async () => { + await request(ctx.getHttpServer()).post(`/admin/users`).send({ + name: 'Test user', + email: 'test@immich.cloud', + password: 'password', + avatarColor: null, + }); + expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ avatarColor: null })); + }); + + it(`should `, async () => { + const dto: UserAdminCreateDto = { + email: 'user@immich.app', + password: 'test', + name: 'Test User', + quotaSizeInBytes: 1.2, + }; + + const { status, body } = await request(ctx.getHttpServer()) + .post(`/admin/users`) + .set('Authorization', `Bearer token`) + .send(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number']))); + }); + it(`should not allow decimal quota`, async () => { const dto: UserAdminCreateDto = { email: 'user@immich.app', @@ -75,5 +118,17 @@ describe(UserAdminController.name, () => { expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number']))); }); + + it('should allow a null pinCode', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ pinCode: null }); + expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ pinCode: null })); + }); + + it('should allow a null avatarColor', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ avatarColor: null }); + expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ avatarColor: null })); + }); }); }); diff --git a/server/src/controllers/user.controller.spec.ts b/server/src/controllers/user.controller.spec.ts index 19f9e919de..3c3e103814 100644 --- a/server/src/controllers/user.controller.spec.ts +++ b/server/src/controllers/user.controller.spec.ts @@ -54,6 +54,14 @@ describe(UserController.name, () => { expect(body).toEqual(errorDto.badRequest()); }); } + + it('should allow an empty avatarColor', async () => { + await request(ctx.getHttpServer()) + .put(`/users/me`) + .set('Authorization', `Bearer token`) + .send({ avatarColor: null }); + expect(service.updateMe).toHaveBeenCalledWith(undefined, expect.objectContaining({ avatarColor: null })); + }); }); describe('GET /users/:id', () => { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 0f46ebaa42..62013fbd92 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -102,7 +102,7 @@ export class UpdateAlbumDto { export class GetAlbumsDto { @ValidateBoolean({ optional: true, - description: 'Filter by shared status: true = only shared, false = only own, undefined = all', + description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums', }) shared?: boolean; diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts new file mode 100644 index 0000000000..e71ffdadd2 --- /dev/null +++ b/server/src/dtos/asset-response.dto.spec.ts @@ -0,0 +1,221 @@ +import { mapAsset } from 'src/dtos/asset-response.dto'; +import { AssetEditAction } from 'src/dtos/editing.dto'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { faceStub } from 'test/fixtures/face.stub'; +import { personStub } from 'test/fixtures/person.stub'; + +describe('mapAsset', () => { + describe('peopleWithFaces', () => { + it('should transform all faces when a person has multiple faces in the same image', () => { + const face1 = { + ...faceStub.primaryFace1, + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + }; + + const face2 = { + ...faceStub.primaryFace1, + id: 'assetFaceId-second', + boundingBoxX1: 300, + boundingBoxY1: 400, + boundingBoxX2: 400, + boundingBoxY2: 500, + imageWidth: 1000, + imageHeight: 800, + }; + + const asset = { + ...assetStub.withCropEdit, + faces: [face1, face2], + exifInfo: { + exifImageWidth: 1000, + exifImageHeight: 800, + }, + }; + + const result = mapAsset(asset as any); + + expect(result.people).toBeDefined(); + expect(result.people).toHaveLength(1); + expect(result.people![0].faces).toHaveLength(2); + + // Verify that both faces have been transformed (bounding boxes adjusted for crop) + const firstFace = result.people![0].faces[0]; + const secondFace = result.people![0].faces[1]; + + // After crop (x: 216, y: 1512), the coordinates should be adjusted + // Faces outside the crop area will be clamped + expect(firstFace.boundingBoxX1).toBe(-116); // 100 - 216 = -116 + expect(firstFace.boundingBoxY1).toBe(-1412); // 100 - 1512 = -1412 + expect(firstFace.boundingBoxX2).toBe(-16); // 200 - 216 = -16 + expect(firstFace.boundingBoxY2).toBe(-1312); // 200 - 1512 = -1312 + + expect(secondFace.boundingBoxX1).toBe(84); // 300 - 216 + expect(secondFace.boundingBoxY1).toBe(-1112); // 400 - 1512 = -1112 + expect(secondFace.boundingBoxX2).toBe(184); // 400 - 216 + expect(secondFace.boundingBoxY2).toBe(-1012); // 500 - 1512 = -1012 + }); + + it('should transform unassigned faces with edits and dimensions', () => { + const unassignedFace = { + ...faceStub.noPerson1, + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + }; + + const asset = { + ...assetStub.withCropEdit, + faces: [unassignedFace], + exifInfo: { + exifImageWidth: 1000, + exifImageHeight: 800, + }, + edits: [ + { + action: AssetEditAction.Crop, + parameters: { x: 50, y: 50, width: 500, height: 400 }, + }, + ], + }; + + const result = mapAsset(asset as any); + + expect(result.unassignedFaces).toBeDefined(); + expect(result.unassignedFaces).toHaveLength(1); + + // Verify that unassigned face has been transformed + const face = result.unassignedFaces![0]; + expect(face.boundingBoxX1).toBe(50); // 100 - 50 + expect(face.boundingBoxY1).toBe(50); // 100 - 50 + expect(face.boundingBoxX2).toBe(150); // 200 - 50 + expect(face.boundingBoxY2).toBe(150); // 200 - 50 + }); + + it('should handle multiple people each with multiple faces', () => { + const person1Face1 = { + ...faceStub.primaryFace1, + id: 'face-1-1', + person: personStub.withName, + personId: personStub.withName.id, + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + }; + + const person1Face2 = { + ...faceStub.primaryFace1, + id: 'face-1-2', + person: personStub.withName, + personId: personStub.withName.id, + boundingBoxX1: 300, + boundingBoxY1: 300, + boundingBoxX2: 400, + boundingBoxY2: 400, + imageWidth: 1000, + imageHeight: 800, + }; + + const person2Face1 = { + ...faceStub.mergeFace1, + id: 'face-2-1', + person: personStub.mergePerson, + personId: personStub.mergePerson.id, + boundingBoxX1: 500, + boundingBoxY1: 100, + boundingBoxX2: 600, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + }; + + const asset = { + ...assetStub.withCropEdit, + faces: [person1Face1, person1Face2, person2Face1], + exifInfo: { + exifImageWidth: 1000, + exifImageHeight: 800, + }, + edits: [], + }; + + const result = mapAsset(asset as any); + + expect(result.people).toBeDefined(); + expect(result.people).toHaveLength(2); + + const person1 = result.people!.find((p) => p.id === personStub.withName.id); + const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id); + + expect(person1).toBeDefined(); + expect(person1!.faces).toHaveLength(2); + // No edits, so coordinates should be unchanged + expect(person1!.faces[0].boundingBoxX1).toBe(100); + expect(person1!.faces[0].boundingBoxY1).toBe(100); + expect(person1!.faces[1].boundingBoxX1).toBe(300); + expect(person1!.faces[1].boundingBoxY1).toBe(300); + + expect(person2).toBeDefined(); + expect(person2!.faces).toHaveLength(1); + expect(person2!.faces[0].boundingBoxX1).toBe(500); + expect(person2!.faces[0].boundingBoxY1).toBe(100); + }); + + it('should combine faces of the same person into a single entry', () => { + const face1 = { + ...faceStub.primaryFace1, + id: 'face-1', + person: personStub.withName, + personId: personStub.withName.id, + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + }; + + const face2 = { + ...faceStub.primaryFace1, + id: 'face-2', + person: personStub.withName, + personId: personStub.withName.id, + boundingBoxX1: 300, + boundingBoxY1: 300, + boundingBoxX2: 400, + boundingBoxY2: 400, + imageWidth: 1000, + imageHeight: 800, + }; + + const asset = { + ...assetStub.withCropEdit, + faces: [face1, face2], + exifInfo: { + exifImageWidth: 1000, + exifImageHeight: 800, + }, + edits: [], + }; + + const result = mapAsset(asset as any); + + expect(result.people).toBeDefined(); + expect(result.people).toHaveLength(1); + + const person = result.people![0]; + expect(person.id).toBe(personStub.withName.id); + expect(person.faces).toHaveLength(2); + }); + }); +}); diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index e163b386be..df02a0cdea 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -193,27 +193,30 @@ export type AssetMapOptions = { auth?: AuthDto; }; -// TODO: this is inefficient const peopleWithFaces = ( faces?: AssetFace[], edits?: AssetEditActionItem[], assetDimensions?: ImageDimensions, ): PersonWithFacesResponseDto[] => { - const result: PersonWithFacesResponseDto[] = []; - if (faces) { - for (const face of faces) { - if (face.person) { - const existingPersonEntry = result.find((item) => item.id === face.person!.id); - if (existingPersonEntry) { - existingPersonEntry.faces.push(face); - } else { - result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] }); - } - } - } + if (!faces) { + return []; } - return result; + const peopleFaces: Map = new Map(); + + for (const face of faces) { + if (!face.person) { + continue; + } + + if (!peopleFaces.has(face.person.id)) { + peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] }); + } + const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions); + peopleFaces.get(face.person.id)!.faces.push(mappedFace); + } + + return [...peopleFaces.values()]; }; const mapStack = (entity: { stack?: Stack | null }) => { @@ -275,7 +278,9 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces, entity.edits, assetDimensions), - unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), + unassignedFaces: entity.faces + ?.filter((face) => !face.person) + .map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)), checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 47226e1503..00ea46f789 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -73,8 +73,7 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { @ValidateUUID({ each: true, description: 'Asset IDs to update' }) ids!: string[]; - @ApiProperty({ description: 'Duplicate asset ID' }) - @Optional() + @ValidateString({ optional: true, nullable: true, description: 'Duplicate ID' }) duplicateId?: string | null; @ApiProperty({ description: 'Relative time offset in seconds' }) diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts index 5331db4e85..87a15f29e3 100644 --- a/server/src/dtos/notification.dto.ts +++ b/server/src/dtos/notification.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; +import { ArrayMinSize, IsString } from 'class-validator'; import { NotificationLevel, NotificationType } from 'src/enum'; -import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; export class TestEmailResponseDto { @ApiProperty({ description: 'Email message ID' }) @@ -75,20 +75,17 @@ export class NotificationCreateDto { @ValidateEnum({ enum: NotificationType, name: 'NotificationType', optional: true, description: 'Notification type' }) type?: NotificationType; - @ApiProperty({ description: 'Notification title' }) - @IsString() + @ValidateString({ description: 'Notification title' }) title!: string; - @ApiPropertyOptional({ description: 'Notification description' }) - @IsString() - @Optional({ nullable: true }) + @ValidateString({ optional: true, nullable: true, description: 'Notification description' }) description?: string | null; @ApiPropertyOptional({ description: 'Additional notification data' }) @Optional({ nullable: true }) data?: any; - @ValidateDate({ optional: true, description: 'Date when notification was read' }) + @ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' }) readAt?: Date | null; @ValidateUUID({ description: 'User ID to send notification to' }) @@ -96,20 +93,22 @@ export class NotificationCreateDto { } export class NotificationUpdateDto { - @ValidateDate({ optional: true, description: 'Date when notification was read' }) + @ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' }) readAt?: Date | null; } export class NotificationUpdateAllDto { - @ValidateUUID({ each: true, optional: true, description: 'Notification IDs to update' }) + @ValidateUUID({ each: true, description: 'Notification IDs to update' }) + @ArrayMinSize(1) ids!: string[]; - @ValidateDate({ optional: true, description: 'Date when notifications were read' }) + @ValidateDate({ optional: true, nullable: true, description: 'Date when notifications were read' }) readAt?: Date | null; } export class NotificationDeleteAllDto { @ValidateUUID({ each: true, description: 'Notification IDs to delete' }) + @ArrayMinSize(1) ids!: string[]; } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 59fddcc71c..47a1889e47 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -97,7 +97,7 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true, description: 'Filter by person IDs' }) personIds?: string[]; - @ValidateUUID({ each: true, optional: true, description: 'Filter by tag IDs' }) + @ValidateUUID({ each: true, optional: true, nullable: true, description: 'Filter by tag IDs' }) tagIds?: string[] | null; @ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' }) diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 7b92f48e28..1465f68953 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -44,7 +44,7 @@ export class SharedLinkCreateDto { @IsString() slug?: string | null; - @ValidateDate({ optional: true, description: 'Expiration date' }) + @ValidateDate({ optional: true, nullable: true, description: 'Expiration date' }) expiresAt?: Date | null = null; @ValidateBoolean({ optional: true, description: 'Allow uploads' }) diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 231e6cc501..bb33659bfe 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -9,7 +9,7 @@ export class TagCreateDto { @IsNotEmpty() name!: string; - @ValidateUUID({ optional: true, description: 'Parent tag ID' }) + @ValidateUUID({ nullable: true, optional: true, description: 'Parent tag ID' }) parentId?: string | null; @ApiPropertyOptional({ description: 'Tag color (hex)' }) @@ -20,7 +20,7 @@ export class TagCreateDto { export class TagUpdateDto { @ApiPropertyOptional({ description: 'Tag color (hex)' }) - @Optional({ emptyToNull: true }) + @Optional({ nullable: true, emptyToNull: true }) @ValidateHexColor() color?: string | null; } diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 598798dc44..2d4fc3934f 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -26,7 +26,13 @@ export class UserUpdateMeDto { @IsNotEmpty() name?: string; - @ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' }) + @ValidateEnum({ + enum: UserAvatarColor, + name: 'UserAvatarColor', + optional: true, + nullable: true, + description: 'Avatar color', + }) avatarColor?: UserAvatarColor | null; } @@ -96,9 +102,19 @@ export class UserAdminCreateDto { @IsString() name!: string; - @ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' }) + @ValidateEnum({ + enum: UserAvatarColor, + name: 'UserAvatarColor', + optional: true, + nullable: true, + description: 'Avatar color', + }) avatarColor?: UserAvatarColor | null; + @ApiPropertyOptional({ description: 'PIN code' }) + @PinCode({ optional: true, nullable: true, emptyToNull: true }) + pinCode?: string | null; + @ApiPropertyOptional({ description: 'Storage label' }) @Optional({ nullable: true }) @IsString() @@ -135,7 +151,7 @@ export class UserAdminUpdateDto { password?: string; @ApiPropertyOptional({ description: 'PIN code' }) - @PinCode({ optional: true, emptyToNull: true }) + @PinCode({ optional: true, nullable: true, emptyToNull: true }) pinCode?: string | null; @ApiPropertyOptional({ description: 'User name' }) @@ -144,7 +160,13 @@ export class UserAdminUpdateDto { @IsNotEmpty() name?: string; - @ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' }) + @ValidateEnum({ + enum: UserAvatarColor, + name: 'UserAvatarColor', + optional: true, + nullable: true, + description: 'Avatar color', + }) avatarColor?: UserAvatarColor | null; @ApiPropertyOptional({ description: 'Storage label' }) diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 50f2c193fc..63174f0b0f 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -132,6 +132,14 @@ where "assetId" = "asset"."id" and "asset_file"."type" = $3 ) + or not exists ( + select + from + "asset_file" + where + "assetId" = "asset"."id" + and "asset_file"."type" = $4 + ) or "asset"."thumbhash" is null ) @@ -430,30 +438,6 @@ select "asset"."originalPath", "asset"."isOffline", to_json("asset_exif") as "exifInfo", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "asset_face".*, - "person" as "person" - from - "asset_face" - left join lateral ( - select - "person".* - from - "person" - where - "asset_face"."personId" = "person"."id" - ) as "person" on true - where - "asset_face"."assetId" = "asset"."id" - and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" is true - ) as agg - ) as "faces", ( select coalesce(json_agg(agg), '[]') @@ -470,27 +454,37 @@ select "asset_file"."assetId" = "asset"."id" ) as agg ) as "files", - to_json("stacked_assets") as "stack" + to_json("stack_result") as "stack" from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" - left join "stack" on "stack"."id" = "asset"."stackId" left join lateral ( select "stack"."id", "stack"."primaryAssetId", - array_agg("stacked") as "assets" + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "stack_asset"."id" + from + "asset" as "stack_asset" + where + "stack_asset"."stackId" = "stack"."id" + and "stack_asset"."id" != "stack"."primaryAssetId" + and "stack_asset"."visibility" = $1 + and "stack_asset"."status" != $2 + ) as agg + ) as "assets" from - "asset" as "stacked" + "stack" where - "stacked"."deletedAt" is not null - and "stacked"."visibility" = $1 - and "stacked"."stackId" = "stack"."id" - group by - "stack"."id" - ) as "stacked_assets" on "stack"."id" is not null + "stack"."id" = "asset"."stackId" + ) as "stack_result" on true where - "asset"."id" = $2 + "asset"."id" = $3 -- AssetJobRepository.streamForVideoConversion select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 1608f7b6f6..4c6e665c4b 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { Kysely } from 'kysely'; +import { Kysely, sql } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { Asset, columns } from 'src/database'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { anyUuid, @@ -15,7 +15,6 @@ import { withExif, withExifInner, withFaces, - withFacesAndPeople, withFilePath, withFiles, } from 'src/utils/database'; @@ -58,8 +57,8 @@ export class AssetJobRepository { .executeTakeFirst(); } - @GenerateSql({ params: [false], stream: true }) - streamForThumbnailJob(force: boolean) { + @GenerateSql({ params: [{ force: false, fullsizeEnabled: true }], stream: true }) + streamForThumbnailJob(options: { force: boolean | undefined; fullsizeEnabled: boolean }) { return this.db .selectFrom('asset') .select(['asset.id', 'asset.thumbhash']) @@ -67,12 +66,12 @@ export class AssetJobRepository { .select(withEdits) .where('asset.deletedAt', 'is', null) .where('asset.visibility', '!=', AssetVisibility.Hidden) - .$if(!force, (qb) => + .$if(!options.force, (qb) => qb // If there aren't any entries, metadata extraction hasn't run yet which is required for thumbnails .innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id') - .where((eb) => - eb.or([ + .where((eb) => { + const conditions = [ eb.not((eb) => eb.exists((qb) => qb @@ -89,9 +88,25 @@ export class AssetJobRepository { .where('asset_file.type', '=', AssetFileType.Thumbnail), ), ), - eb('asset.thumbhash', 'is', null), - ]), - ), + ]; + + if (options.fullsizeEnabled) { + conditions.push( + eb.not((eb) => + eb.exists((qb) => + qb + .selectFrom('asset_file') + .whereRef('assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.FullSize), + ), + ), + ); + } + + conditions.push(eb('asset.thumbhash', 'is', null)); + + return eb.or(conditions); + }), ) .stream(); } @@ -269,23 +284,29 @@ export class AssetJobRepository { 'asset.isOffline', ]) .$call(withExif) - .select(withFacesAndPeople) .select(withFiles) - .leftJoin('stack', 'stack.id', 'asset.stackId') .leftJoinLateral( (eb) => eb - .selectFrom('asset as stacked') - .select(['stack.id', 'stack.primaryAssetId']) - .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) - .where('stacked.deletedAt', 'is not', null) - .where('stacked.visibility', '=', AssetVisibility.Timeline) - .whereRef('stacked.stackId', '=', 'stack.id') - .groupBy('stack.id') - .as('stacked_assets'), - (join) => join.on('stack.id', 'is not', null), + .selectFrom('stack') + .whereRef('stack.id', '=', 'asset.stackId') + .select((eb) => [ + 'stack.id', + 'stack.primaryAssetId', + jsonArrayFrom( + eb + .selectFrom('asset as stack_asset') + .select(['stack_asset.id']) + .whereRef('stack_asset.stackId', '=', 'stack.id') + .whereRef('stack_asset.id', '!=', 'stack.primaryAssetId') + .where('stack_asset.visibility', '=', sql.val(AssetVisibility.Timeline)) + .where('stack_asset.status', '!=', sql.val(AssetStatus.Deleted)), + ).as('assets'), + ]) + .as('stack_result'), + (join) => join.onTrue(), ) - .select((eb) => toJson(eb, 'stacked_assets').as('stack')) + .select((eb) => toJson(eb, 'stack_result').as('stack')) .where('asset.id', '=', id) .executeTakeFirst(); } diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 7345dfef5b..5a1a936e77 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -152,7 +152,7 @@ export class StorageRepository { } async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) { - await fs.rm(folder, options); + await fs.rm(folder, { ...options, maxRetries: 5, retryDelay: 100 }); } async removeEmptyDirs(directory: string, self: boolean = false) { @@ -168,7 +168,13 @@ export class StorageRepository { if (self) { const updated = await fs.readdir(directory); if (updated.length === 0) { - await fs.rmdir(directory); + try { + await fs.rmdir(directory); + } catch (error: Error | any) { + if (error.code !== 'ENOTEMPTY') { + this.logger.warn(`Attempted to remove directory, but failed: ${error}`); + } + } } } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index eca49bc14e..707faa326d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -8,7 +8,6 @@ import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { faceStub } from 'test/fixtures/face.stub'; import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -565,12 +564,11 @@ describe(AssetService.name, () => { }); describe('handleAssetDeletion', () => { - it('should remove faces', async () => { - const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; + it('should clean up files', async () => { + const asset = assetStub.image; + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetWithFace); - - await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true }); + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); expect(mocks.job.queue.mock.calls).toEqual([ [ @@ -581,38 +579,29 @@ describe(AssetService.name, () => { '/uploads/user-id/webp/path.ext', '/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/fullsize/path.webp', - assetWithFace.originalPath, + asset.originalPath, ], }, }, ], ]); - - expect(mocks.asset.remove).toHaveBeenCalledWith(assetWithFace); - }); - - it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { - mocks.stack.update.mockResolvedValue(factory.stack() as any); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.primaryImage); - - await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - - expect(mocks.stack.update).toHaveBeenCalledWith('stack-1', { - id: 'stack-1', - primaryAssetId: 'stack-child-asset-1', - }); + expect(mocks.asset.remove).toHaveBeenCalledWith(asset); }); it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { mocks.stack.delete.mockResolvedValue(); mocks.assetJob.getForAssetDeletion.mockResolvedValue({ ...assetStub.primaryImage, - stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, + stack: { + id: 'stack-id', + primaryAssetId: assetStub.primaryImage.id, + assets: [{ id: 'one-asset' }], + }, }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(mocks.stack.delete).toHaveBeenCalledWith('stack-1'); + expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); }); it('should delete a live photo', async () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 066084ed45..ed427684f1 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -46,6 +46,7 @@ import { onBeforeUnlink, } from 'src/utils/asset.util'; import { updateLockedColumns } from 'src/utils/database'; +import { extractTimeZone } from 'src/utils/date'; import { transformOcrBoundingBox } from 'src/utils/transform'; @Injectable() @@ -168,12 +169,13 @@ export class AssetService extends BaseService { }, _.isUndefined, ); - const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined; if (Object.keys(exifDto).length > 0) { await this.assetRepository.updateAllExif(ids, exifDto); } + const extractedTimeZone = extractTimeZone(dateTimeOriginal); + if ( (dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined || @@ -327,10 +329,11 @@ export class AssetService extends BaseService { return JobStatus.Failed; } - // Replace the parent of the stack children with a new asset + // replace the parent of the stack children with a new asset if (asset.stack?.primaryAssetId === id) { - const stackAssetIds = asset.stack?.assets.map((a) => a.id) ?? []; - if (stackAssetIds.length > 2) { + // this only includes timeline visible assets and excludes the primary asset + const stackAssetIds = asset.stack.assets.map((a) => a.id); + if (stackAssetIds.length >= 2) { const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; await this.stackRepository.update(asset.stack.id, { id: asset.stack.id, @@ -512,12 +515,11 @@ export class AssetService extends BaseService { rating?: number; }) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; - const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined; const writes = _.omitBy( { description, dateTimeOriginal, - timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined, + timeZone: extractTimeZone(dateTimeOriginal)?.name, latitude, longitude, rating, diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index 5dabb4d731..de7090fa83 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -152,7 +152,7 @@ export class DatabaseBackupService { args.push( '--username', - databaseConfig.username, + databaseUsername, '--host', databaseConfig.host, '--port', diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index fa2607faa9..75812e2fcb 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -23,8 +23,14 @@ import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; +import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; +const filesNoFullsize = [ + factory.assetFile({ type: AssetFileType.Preview }), + factory.assetFile({ type: AssetFileType.Thumbnail }), +]; + const fullsizeBuffer = Buffer.from('embedded image data'); const rawBuffer = Buffer.from('raw image data'); const extractedBuffer = Buffer.from('embedded image file'); @@ -49,7 +55,7 @@ describe(MediaService.name, () => { await sut.handleQueueGenerateThumbnails({ force: true }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, @@ -72,7 +78,7 @@ describe(MediaService.name, () => { await sut.handleQueueGenerateThumbnails({ force: true }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, @@ -87,7 +93,7 @@ describe(MediaService.name, () => { await sut.handleQueueGenerateThumbnails({ force: true }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, @@ -103,7 +109,7 @@ describe(MediaService.name, () => { await sut.handleQueueGenerateThumbnails({ force: false }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); expect(mocks.person.getRandomFace).toHaveBeenCalled(); expect(mocks.person.update).toHaveBeenCalledTimes(1); @@ -122,7 +128,7 @@ describe(MediaService.name, () => { mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, @@ -138,7 +144,7 @@ describe(MediaService.name, () => { mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, @@ -154,7 +160,7 @@ describe(MediaService.name, () => { mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, @@ -165,12 +171,43 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); + it('should queue all assets with missing fullsize when feature is enabled', async () => { + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); + const asset = { id: factory.uuid(), thumbhash: factory.buffer(), edits: [], files: filesNoFullsize }; + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: false }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: true }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.AssetGenerateThumbnails, + data: { id: asset.id }, + }, + ]); + + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + }); + + it('should not queue assets with missing fullsize when feature is disabled', async () => { + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); + const asset = { id: factory.uuid(), thumbhash: factory.buffer(), edits: [], files: filesNoFullsize }; + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: false }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); + + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + }); + it('should queue assets with edits but missing edited thumbnails', async () => { mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetEditThumbnailGeneration, @@ -181,12 +218,42 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); + it('should not queue assets with missing edited fullsize when feature is disabled', async () => { + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: false }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); + + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + }); + + it('should queue assets with missing fullsize when force is true, regardless of setting', async () => { + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); + const asset = { id: factory.uuid(), thumbhash: Buffer.from('thumbhash'), edits: [], files: filesNoFullsize }; + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: true }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.AssetGenerateThumbnails, + data: { id: asset.id }, + }, + ]); + + expect(mocks.person.getAll).toHaveBeenCalled(); + }); + it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => { mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index b9b8d74737..00bd0305dd 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -68,6 +68,7 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration }) async handleQueueGenerateThumbnails({ force }: JobOf): Promise { + const config = await this.getConfig({ withCache: true }); let jobs: JobItem[] = []; const queueAll = async () => { @@ -75,16 +76,18 @@ export class MediaService extends BaseService { jobs = []; }; - for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) { - const assetFiles = getAssetFiles(asset.files); + const fullsizeEnabled = config.image.fullsize.enabled; + for await (const asset of this.assetJobRepository.streamForThumbnailJob({ force, fullsizeEnabled })) { + const { previewFile, thumbnailFile, fullsizeFile, editedPreviewFile, editedThumbnailFile, editedFullsizeFile } = + getAssetFiles(asset.files); - if (!assetFiles.previewFile || !assetFiles.thumbnailFile || !asset.thumbhash || force) { + if (force || !previewFile || !thumbnailFile || !asset.thumbhash || (fullsizeEnabled && !fullsizeFile)) { jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }); } if ( asset.edits.length > 0 && - (!assetFiles.editedPreviewFile || !assetFiles.editedThumbnailFile || !assetFiles.editedFullsizeFile || force) + (force || !editedPreviewFile || !editedThumbnailFile || (fullsizeEnabled && !editedFullsizeFile)) ) { jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 942817a213..eda4e1a063 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1766,13 +1766,14 @@ describe(MetadataService.name, () => { const asset = factory.jobAssets.sidecarWrite(); const description = 'this is a description'; const gps = 12; - const date = '2023-11-22T04:56:12.196Z'; + const date = '2023-11-21T22:56:12.196-06:00'; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([ 'description', 'latitude', 'longitude', 'dateTimeOriginal', + 'timeZone', ]); mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); await expect( @@ -1792,6 +1793,7 @@ describe(MetadataService.name, () => { 'latitude', 'longitude', 'dateTimeOriginal', + 'timeZone', ]); }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index f74f9f4cec..4113025914 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -32,6 +32,7 @@ import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { isAssetChecksumConstraint } from 'src/utils/database'; +import { mergeTimeZone } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; @@ -307,7 +308,6 @@ export class MetadataService extends BaseService { const assetHeight = isSidewards ? validate(width) : validate(height); const promises: Promise[] = [ - this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }), this.assetRepository.update({ id: asset.id, duration: this.getDuration(exifTags), @@ -322,6 +322,7 @@ export class MetadataService extends BaseService { }), ]; + await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }); await this.applyTagList(asset); if (this.isMotionPhoto(asset, exifTags)) { @@ -431,14 +432,16 @@ export class MetadataService extends BaseService { const { sidecarFile } = getAssetFiles(asset.files); const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`; - const { description, dateTimeOriginal, latitude, longitude, rating, tags } = _.pick( + const { description, dateTimeOriginal, latitude, longitude, rating, tags, timeZone } = _.pick( { description: asset.exifInfo.description, - dateTimeOriginal: asset.exifInfo.dateTimeOriginal, + // the kysely type is wrong here; fixed in 0.28.3 + dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null, latitude: asset.exifInfo.latitude, longitude: asset.exifInfo.longitude, rating: asset.exifInfo.rating, tags: asset.exifInfo.tags, + timeZone: asset.exifInfo.timeZone, }, lockedProperties, ); @@ -447,7 +450,7 @@ export class MetadataService extends BaseService { { Description: description, ImageDescription: description, - DateTimeOriginal: dateTimeOriginal ? String(dateTimeOriginal) : undefined, + DateTimeOriginal: mergeTimeZone(dateTimeOriginal, timeZone)?.toISO(), GPSLatitude: latitude, GPSLongitude: longitude, Rating: rating, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index dfbb56bd1e..52a4e6048f 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -44,6 +44,7 @@ import { getDimensions } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFacialRecognitionEnabled } from 'src/utils/misc'; +import { Point, transformPoints } from 'src/utils/transform'; @Injectable() export class PersonService extends BaseService { @@ -634,15 +635,61 @@ export class PersonService extends BaseService { this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }), ]); + const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }); + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + const edits = asset.edits || []; + + let topLeft: Point = { x: dto.x, y: dto.y }; + let bottomRight: Point = { x: dto.x + dto.width, y: dto.y + dto.height }; + + // the coordinates received from the client are based on the edited preview image + // we need to convert them to the coordinate space of the original unedited image + if (edits.length > 0) { + if (!asset.width || !asset.height || !asset.exifInfo?.exifImageWidth || !asset.exifInfo?.exifImageHeight) { + throw new BadRequestException('Asset does not have valid dimensions'); + } + + // convert from preview to full dimensions + const scaleFactor = asset.width / dto.imageWidth; + topLeft = { x: topLeft.x * scaleFactor, y: topLeft.y * scaleFactor }; + bottomRight = { x: bottomRight.x * scaleFactor, y: bottomRight.y * scaleFactor }; + + const { + points: [invertedTopLeft, invertedBottomRight], + } = transformPoints( + [topLeft, bottomRight], + edits, + { width: asset.width, height: asset.height }, + { inverse: true }, + ); + + // make sure topLeft is top-left and bottomRight is bottom-right + topLeft = { + x: Math.min(invertedTopLeft.x, invertedBottomRight.x), + y: Math.min(invertedTopLeft.y, invertedBottomRight.y), + }; + bottomRight = { + x: Math.max(invertedTopLeft.x, invertedBottomRight.x), + y: Math.max(invertedTopLeft.y, invertedBottomRight.y), + }; + + // now coordinates are in original image space + dto.imageHeight = asset.exifInfo.exifImageHeight; + dto.imageWidth = asset.exifInfo.exifImageWidth; + } + await this.personRepository.createAssetFace({ personId: dto.personId, assetId: dto.assetId, imageHeight: dto.imageHeight, imageWidth: dto.imageWidth, - boundingBoxX1: dto.x, - boundingBoxX2: dto.x + dto.width, - boundingBoxY1: dto.y, - boundingBoxY2: dto.y + dto.height, + boundingBoxX1: Math.round(topLeft.x), + boundingBoxX2: Math.round(bottomRight.x), + boundingBoxY1: Math.round(topLeft.y), + boundingBoxY2: Math.round(bottomRight.y), sourceType: SourceType.Manual, }); } diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 84c7b578dd..7872f720a9 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -130,7 +130,7 @@ describe(VersionService.name, () => { }); }); - describe('onWebsocketConnectionEvent', () => { + describe('onWebsocketConnection', () => { it('should send on_server_version client event', async () => { await sut.onWebsocketConnection({ userId: '42' }); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); @@ -143,5 +143,12 @@ describe(VersionService.name, () => { expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); + + it('should not send a release notification when the version check is disabled', async () => { + mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } }); + await sut.onWebsocketConnection({ userId: '42' }); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); + }); }); }); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 2d3924bc49..fd51fa9adf 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -105,6 +105,12 @@ export class VersionService extends BaseService { @OnEvent({ name: 'WebsocketConnect' }) async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) { this.websocketRepository.clientSend('on_server_version', userId, serverVersion); + + const { newVersionCheck } = await this.getConfig({ withCache: true }); + if (!newVersionCheck.enabled) { + return; + } + const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); if (metadata) { this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata)); diff --git a/server/src/utils/date.ts b/server/src/utils/date.ts index 67ce549050..6cef48ecf8 100644 --- a/server/src/utils/date.ts +++ b/server/src/utils/date.ts @@ -1,3 +1,16 @@ +import { DateTime } from 'luxon'; + export const asDateString = (x: Date | string | null): string | null => { return x instanceof Date ? x.toISOString().split('T')[0] : x; }; + +export const extractTimeZone = (dateTimeOriginal?: string | null) => { + const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined; + return extractedTimeZone?.type === 'fixed' ? extractedTimeZone : undefined; +}; + +export const mergeTimeZone = (dateTimeOriginal?: string | null, timeZone?: string | null) => { + return dateTimeOriginal + ? DateTime.fromISO(dateTimeOriginal, { zone: 'UTC' }).setZone(timeZone ?? undefined) + : undefined; +}; diff --git a/server/src/utils/transform.ts b/server/src/utils/transform.ts index b57a198cc6..261595eb66 100644 --- a/server/src/utils/transform.ts +++ b/server/src/utils/transform.ts @@ -61,7 +61,7 @@ export const createAffineMatrix = ( ); }; -type Point = { x: number; y: number }; +export type Point = { x: number; y: number }; type TransformState = { points: Point[]; @@ -73,29 +73,33 @@ type TransformState = { * Transforms an array of points through a series of edit operations (crop, rotate, mirror). * Points should be in absolute pixel coordinates relative to the starting dimensions. */ -const transformPoints = ( +export const transformPoints = ( points: Point[], edits: AssetEditActionItem[], startingDimensions: ImageDimensions, + { inverse = false } = {}, ): TransformState => { let currentWidth = startingDimensions.width; let currentHeight = startingDimensions.height; let transformedPoints = [...points]; - // Handle crop first - const crop = edits.find((edit) => edit.action === 'crop'); - if (crop) { - const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters; - transformedPoints = transformedPoints.map((p) => ({ - x: p.x - cropX, - y: p.y - cropY, - })); - currentWidth = cropWidth; - currentHeight = cropHeight; + // Handle crop first if not inverting + if (!inverse) { + const crop = edits.find((edit) => edit.action === 'crop'); + if (crop) { + const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters; + transformedPoints = transformedPoints.map((p) => ({ + x: p.x - cropX, + y: p.y - cropY, + })); + currentWidth = cropWidth; + currentHeight = cropHeight; + } } // Apply rotate and mirror transforms - for (const edit of edits) { + const editSequence = inverse ? edits.toReversed() : edits; + for (const edit of editSequence) { let matrix: Matrix = identity(); if (edit.action === 'rotate') { const angleDegrees = edit.parameters.angle; @@ -105,7 +109,7 @@ const transformPoints = ( matrix = compose( translate(newWidth / 2, newHeight / 2), - rotate(angleRadians), + rotate(inverse ? -angleRadians : angleRadians), translate(-currentWidth / 2, -currentHeight / 2), ); @@ -125,6 +129,18 @@ const transformPoints = ( transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p)); } + // Handle crop last if inverting + if (inverse) { + const crop = edits.find((edit) => edit.action === 'crop'); + if (crop) { + const { x: cropX, y: cropY } = crop.parameters; + transformedPoints = transformedPoints.map((p) => ({ + x: p.x + cropX, + y: p.y + cropY, + })); + } + } + return { points: transformedPoints, currentWidth, diff --git a/server/src/validation.ts b/server/src/validation.ts index 724c01ffe9..cdca1bc0ca 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -232,19 +232,20 @@ export const ValidateHexColor = () => { return applyDecorators(...decorators); }; -type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; +type DateOptions = OptionalOptions & { optional?: boolean; format?: 'date' | 'date-time' }; export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { - const { optional, nullable, format, ...apiPropertyOptions } = { - optional: false, - nullable: false, - format: 'date-time', - ...options, - }; + const { + optional, + nullable = false, + emptyToNull = false, + format = 'date-time', + ...apiPropertyOptions + } = options || {}; - const decorators = [ + return applyDecorators( ApiProperty({ format, ...apiPropertyOptions }), IsDate(), - optional ? Optional({ nullable: true }) : IsNotEmpty(), + optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(), Transform(({ key, value }) => { if (value === null || value === undefined) { return value; @@ -256,19 +257,17 @@ export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { return new Date(value as string); }), - ]; - - if (optional) { - decorators.push(Optional({ nullable })); - } - - return applyDecorators(...decorators); + ); }; -type StringOptions = { optional?: boolean; nullable?: boolean; trim?: boolean }; +type StringOptions = OptionalOptions & { optional?: boolean; trim?: boolean }; export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => { - const { optional, nullable, trim, ...apiPropertyOptions } = options || {}; - const decorators = [ApiProperty(apiPropertyOptions), IsString(), optional ? Optional({ nullable }) : IsNotEmpty()]; + const { optional, nullable, emptyToNull, trim, ...apiPropertyOptions } = options || {}; + const decorators = [ + ApiProperty(apiPropertyOptions), + IsString(), + optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(), + ]; if (trim) { decorators.push(Transform(({ value }: { value: string }) => value?.trim())); @@ -277,9 +276,9 @@ export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => return applyDecorators(...decorators); }; -type BooleanOptions = { optional?: boolean; nullable?: boolean }; +type BooleanOptions = OptionalOptions & { optional?: boolean }; export const ValidateBoolean = (options?: BooleanOptions & PropertyOptions) => { - const { optional, nullable, ...apiPropertyOptions } = options || {}; + const { optional, nullable, emptyToNull, ...apiPropertyOptions } = options || {}; const decorators = [ Property(apiPropertyOptions), IsBoolean(), @@ -291,7 +290,7 @@ export const ValidateBoolean = (options?: BooleanOptions & PropertyOptions) => { } return value; }), - optional ? Optional({ nullable }) : IsNotEmpty(), + optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(), ]; return applyDecorators(...decorators); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 153b568222..f1b87b50d7 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -6,6 +6,7 @@ import { Stats } from 'node:fs'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; +import { AssetEditActionListDto } from 'src/dtos/editing.dto'; import { AlbumUserRole, AssetType, @@ -280,6 +281,11 @@ export class MediumTestContext { const result = await this.get(TagRepository).upsertAssetIds(tagsAssets); return { tagsAssets, result }; } + + async newEdits(assetId: string, dto: AssetEditActionListDto) { + const edits = await this.get(AssetEditRepository).replaceAll(assetId, dto.edits); + return { edits }; + } } export class SyncTestContext extends MediumTestContext { diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index d0949c153c..29e7ea7039 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, AssetStatus, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; @@ -246,6 +246,66 @@ describe(AssetService.name, () => { }); }); + it('should delete a stacked primary asset (2 assets)', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + const { stack, result } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]); + + const stackRepo = ctx.get(StackRepository); + + expect(result).toMatchObject({ primaryAssetId: asset1.id }); + + await sut.handleAssetDeletion({ id: asset1.id, deleteOnDisk: true }); + + // stack is deleted as well + await expect(stackRepo.getById(stack.id)).resolves.toBe(undefined); + }); + + it('should delete a stacked primary asset (3 assets)', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset3 } = await ctx.newAsset({ ownerId: user.id }); + const { stack, result } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id, asset3.id]); + + expect(result).toMatchObject({ primaryAssetId: asset1.id }); + + await sut.handleAssetDeletion({ id: asset1.id, deleteOnDisk: true }); + + // new primary asset is picked + await expect(ctx.get(StackRepository).getById(stack.id)).resolves.toMatchObject({ primaryAssetId: asset2.id }); + }); + + it('should delete a stacked primary asset (3 trashed assets)', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset3 } = await ctx.newAsset({ ownerId: user.id }); + const { stack, result } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id, asset3.id]); + + await ctx.get(AssetRepository).updateAll([asset1.id, asset2.id, asset3.id], { + deletedAt: new Date(), + status: AssetStatus.Deleted, + }); + + expect(result).toMatchObject({ primaryAssetId: asset1.id }); + + await sut.handleAssetDeletion({ id: asset1.id, deleteOnDisk: true }); + + // stack is deleted as well + await expect(ctx.get(StackRepository).getById(stack.id)).resolves.toBe(undefined); + }); + it('should not delete offline assets', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); @@ -396,6 +456,47 @@ describe(AssetService.name, () => { ); }); + it('should relatively update an assets with timezone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00', timeZone: 'UTC+5' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -1441 }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-18T18:10:00+00:00', + timeZone: 'UTC+5', + lockedProperties: ['timeZone', 'dateTimeOriginal'], + }), + }), + ); + }); + + it('should relatively update an assets and set a timezone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11, timeZone: 'UTC+5' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-19T18:00:00+00:00', + timeZone: 'UTC+5', + }), + }), + ); + }); + it('should update dateTimeOriginal', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index f26834c5e2..a13f64032c 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -1,5 +1,9 @@ import { Kysely } from 'kysely'; +import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; +import { AssetFaceCreateDto } from 'src/dtos/person.dto'; import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -15,7 +19,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(PersonService, { database: db || defaultDatabase, - real: [AccessRepository, DatabaseRepository, PersonRepository], + real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository], mock: [LoggingRepository, StorageRepository], }); }; @@ -77,4 +81,609 @@ describe(PersonService.name, () => { expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath); }); }); + + describe('createFace', () => { + it('should store and retrieve the face as-is when there are no edits', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 200, + imageHeight: 200, + x: 50, + y: 50, + width: 150, + height: 150, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + // retrieve an asset's faces + const faces = sut.getFacesById(auth, { id: asset.id }); + + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 50, + boundingBoxY1: 50, + boundingBoxX2: 200, + boundingBoxY2: 200, + }), + ]), + ); + }); + + it('should properly transform the coordinates when the asset is edited (Crop)', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Crop, + parameters: { + x: 50, + y: 50, + width: 150, + height: 200, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 150, + imageHeight: 200, + x: 0, + y: 0, + width: 100, + height: 100, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + // retrieve an asset's faces + const faces = sut.getFacesById(auth, { id: asset.id }); + + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 100, + boundingBoxY2: 100, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toHaveLength(1); + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 50, + boundingBoxY1: 50, + boundingBoxX2: 150, + boundingBoxY2: 150, + }), + ]), + ); + }); + + it('should properly transform the coordinates when the asset is edited (Rotate 90)', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 200 }); + await ctx.newExif({ assetId: asset.id, exifImageWidth: 200, exifImageHeight: 100 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Rotate, + parameters: { + angle: 90, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 100, + imageHeight: 200, + x: 25, + y: 50, + width: 10, + height: 10, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + const faces = sut.getFacesById(auth, { id: asset.id }); + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: expect.closeTo(25, 1), + boundingBoxY1: expect.closeTo(50, 1), + boundingBoxX2: expect.closeTo(35, 1), + boundingBoxY2: expect.closeTo(60, 1), + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 50, + boundingBoxY1: 65, + boundingBoxX2: 60, + boundingBoxY2: 75, + }), + ]), + ); + }); + + it('should properly transform the coordinates when the asset is edited (Mirror Horizontal)', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 200, + imageHeight: 100, + x: 50, + y: 25, + width: 100, + height: 50, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + const faces = sut.getFacesById(auth, { id: asset.id }); + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 50, + boundingBoxY1: 25, + boundingBoxX2: 150, + boundingBoxY2: 75, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 50, + boundingBoxY1: 25, + boundingBoxX2: 150, + boundingBoxY2: 75, + }), + ]), + ); + }); + + it('should properly transform the coordinates when the asset is edited (Crop + Rotate)', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Crop, + parameters: { + x: 50, + y: 0, + width: 150, + height: 200, + }, + }, + { + action: AssetEditAction.Rotate, + parameters: { + angle: 90, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 200, + imageHeight: 150, + x: 50, + y: 25, + width: 10, + height: 20, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + const faces = sut.getFacesById(auth, { id: asset.id }); + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: expect.closeTo(50, 1), + boundingBoxY1: expect.closeTo(25, 1), + boundingBoxX2: expect.closeTo(60, 1), + boundingBoxY2: expect.closeTo(45, 1), + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 75, + boundingBoxY1: 140, + boundingBoxX2: 95, + boundingBoxY2: 150, + }), + ]), + ); + }); + + it('should properly transform the coordinates when the asset is edited (Crop + Mirror)', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Crop, + parameters: { + x: 50, + y: 0, + width: 150, + height: 100, + }, + }, + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 150, + imageHeight: 100, + x: 25, + y: 25, + width: 75, + height: 50, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + const faces = sut.getFacesById(auth, { id: asset.id }); + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 25, + boundingBoxY1: 25, + boundingBoxX2: 100, + boundingBoxY2: 75, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 100, + boundingBoxY1: 25, + boundingBoxX2: 175, + boundingBoxY2: 75, + }), + ]), + ); + }); + + it('should properly transform the coordinates when the asset is edited (Rotate + Mirror)', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Rotate, + parameters: { + angle: 90, + }, + }, + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 200, + imageHeight: 150, + x: 50, + y: 25, + width: 15, + height: 20, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + const faces = sut.getFacesById(auth, { id: asset.id }); + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: expect.closeTo(50, 1), + boundingBoxY1: expect.closeTo(25, 1), + boundingBoxX2: expect.closeTo(65, 1), + boundingBoxY2: expect.closeTo(45, 1), + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 25, + boundingBoxY1: 50, + boundingBoxX2: 45, + boundingBoxY2: 65, + }), + ]), + ); + }); + + it('should properly transform the coordinates when the asset is edited (Crop + Rotate + Mirror)', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Crop, + parameters: { + x: 50, + y: 25, + width: 100, + height: 150, + }, + }, + { + action: AssetEditAction.Rotate, + parameters: { + angle: 270, + }, + }, + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 150, + imageHeight: 150, + x: 25, + y: 50, + width: 75, + height: 50, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + const faces = sut.getFacesById(auth, { id: asset.id }); + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: expect.closeTo(25, 1), + boundingBoxY1: expect.closeTo(50, 1), + boundingBoxX2: expect.closeTo(100, 1), + boundingBoxY2: expect.closeTo(100, 1), + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 50, + boundingBoxY1: 75, + boundingBoxX2: 100, + boundingBoxY2: 150, + }), + ]), + ); + }); + + it('should properly transform the coordinates with multiple mirrors in sequence', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }, + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Vertical, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 100, + imageHeight: 100, + x: 10, + y: 10, + width: 80, + height: 80, + personId: person.id, + assetId: asset.id, + }; + + await sut.createFace(auth, dto); + + const faces = sut.getFacesById(auth, { id: asset.id }); + await expect(faces).resolves.toHaveLength(1); + await expect(faces).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 10, + boundingBoxY1: 10, + boundingBoxX2: 90, + boundingBoxY2: 90, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.newEdits(asset.id, { edits: [] }); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 10, + boundingBoxY1: 10, + boundingBoxX2: 90, + boundingBoxY2: 90, + }), + ]), + ); + }); + }); }); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 2d29386d67..579da1a2d8 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -361,6 +361,7 @@ const assetSidecarWriteFactory = () => { latitude: 12, longitude: 12, dateTimeOriginal: '2023-11-22T04:56:12.196Z', + timeZone: 'UTC-6', } as unknown as Exif, }; }; @@ -532,6 +533,7 @@ export const factory = { assetEdit: assetEditFactory, tag: tagFactory, uuid: newUuid, + buffer: () => Buffer.from('this is a fake buffer'), date: newDate, responses: { badRequest: (message: any = null) => ({ diff --git a/server/test/vitest.config.mjs b/server/test/vitest.config.mjs index 6d9ee3a564..79d053d176 100644 --- a/server/test/vitest.config.mjs +++ b/server/test/vitest.config.mjs @@ -2,9 +2,6 @@ import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; -// Set the timezone to UTC to avoid timezone issues during testing -process.env.TZ = 'UTC'; - export default defineConfig({ test: { root: './', @@ -25,6 +22,9 @@ export default defineConfig({ fallbackCJS: true, }, }, + env: { + TZ: 'UTC', + }, }, plugins: [swc.vite(), tsconfigPaths()], }); diff --git a/web/package.json b/web/package.json index 6433a33dcc..971becec09 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "2.5.2", + "version": "2.5.5", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.59.0", + "@immich/ui": "^0.61.3", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/__mocks__/resize-observer.mock.ts b/web/src/lib/__mocks__/resize-observer.mock.ts new file mode 100644 index 0000000000..ffd1dad2fd --- /dev/null +++ b/web/src/lib/__mocks__/resize-observer.mock.ts @@ -0,0 +1,8 @@ +import { vi } from 'vitest'; + +export const getResizeObserverMock = () => + vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), + })); diff --git a/web/src/lib/components/AssetViewerEvents.svelte b/web/src/lib/components/AssetViewerEvents.svelte index 148da0e258..b636908b76 100644 --- a/web/src/lib/components/AssetViewerEvents.svelte +++ b/web/src/lib/components/AssetViewerEvents.svelte @@ -22,5 +22,3 @@ return assetViewerManager.on(events); }); - -const event = name.slice(2) as keyof Events; diff --git a/web/src/lib/components/SharedLinkFormFields.spec.ts b/web/src/lib/components/SharedLinkFormFields.spec.ts new file mode 100644 index 0000000000..9c65c43833 --- /dev/null +++ b/web/src/lib/components/SharedLinkFormFields.spec.ts @@ -0,0 +1,34 @@ +import { render } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import SharedLinkFormFields from './SharedLinkFormFields.svelte'; + +describe('SharedLinkFormFields component', () => { + const isChecked = (element: Element) => + element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true'; + + it('turns downloads off when metadata is disabled', async () => { + const { container } = render(SharedLinkFormFields, { + props: { + slug: '', + password: '', + description: '', + allowDownload: true, + allowUpload: false, + showMetadata: true, + expiresAt: null, + }, + }); + const user = userEvent.setup(); + + const switches = Array.from(container.querySelectorAll('[role="switch"], input[type="checkbox"]')); + expect(switches).toHaveLength(3); + + const [showMetadataSwitch, allowDownloadSwitch] = switches; + expect(isChecked(allowDownloadSwitch)).toBe(true); + + await user.click(showMetadataSwitch); + + expect(isChecked(showMetadataSwitch)).toBe(false); + expect(isChecked(allowDownloadSwitch)).toBe(false); + }); +}); diff --git a/web/src/lib/components/SharedLinkFormFields.svelte b/web/src/lib/components/SharedLinkFormFields.svelte new file mode 100644 index 0000000000..1e7b3b754b --- /dev/null +++ b/web/src/lib/components/SharedLinkFormFields.svelte @@ -0,0 +1,65 @@ + + +
+
+ + + + {#if slug} + /s/{encodeURIComponent(slug)} + {/if} +
+ + + + + + + + + + + + + + + + + + + + + +
diff --git a/web/src/lib/components/VersionAnnouncement.svelte b/web/src/lib/components/VersionAnnouncement.svelte new file mode 100644 index 0000000000..138813735f --- /dev/null +++ b/web/src/lib/components/VersionAnnouncement.svelte @@ -0,0 +1,41 @@ + + + diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index 098ce23259..aec1761998 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -105,7 +105,7 @@ { Element.prototype.animate = vi.fn().mockImplementation(() => ({ cancel: () => {}, })); - vi.stubGlobal( - 'ResizeObserver', - vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })), - ); + vi.stubGlobal('ResizeObserver', getResizeObserverMock()); vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { return { featureFlagsManager: { diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 333e76d2ea..848870b654 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -14,6 +14,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import { imageManager } from '$lib/managers/ImageManager.svelte'; import { Route } from '$lib/route'; + import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; @@ -36,6 +37,7 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; + import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; @@ -174,6 +176,7 @@ } activityManager.reset(); + assetViewerManager.closeEditor(); }); const handleGetAllAlbums = async () => { @@ -194,9 +197,7 @@ const closeEditor = async () => { if (editManager.hasAppliedEdits) { - console.log(asset); const refreshedAsset = await getAssetInfo({ id: asset.id }); - console.log(refreshedAsset); onAssetChange?.(refreshedAsset); assetViewingStore.setAsset(refreshedAsset); } @@ -428,8 +429,11 @@ !assetViewerManager.isShowEditor && ocrManager.hasOcrData, ); + + const { Tag } = $derived(getAssetActions($t, asset)); + diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index cd9b1a40d2..09c4432723 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -1,12 +1,13 @@ - + {#if isOwner && !authManager.isSharedLink}
@@ -42,36 +44,24 @@
{#each tags as tag (tag.id)} -
- + -

- {tag.value} -

-
- - -
+ size="tiny" + class="hover:bg-primary-400" + shape="round" + /> + {/each} - +
{/if} diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index c52b63793b..d9a344cdc8 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -75,7 +75,7 @@ - - -
- - -
- +
+
+ {$t('change_pin_code')} + + + +
- + +
+ + +
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte index 1d18bc2448..53530b694d 100644 --- a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte @@ -20,7 +20,7 @@ -
+
{#if hasPinCode}
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 5c3b59fafe..3e82b76418 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -59,7 +59,7 @@
-
+
themeManager.setSystem(checked)} /> diff --git a/web/src/lib/components/user-settings-page/change-password-settings.svelte b/web/src/lib/components/user-settings-page/change-password-settings.svelte index 8165f2e508..2579862056 100644 --- a/web/src/lib/components/user-settings-page/change-password-settings.svelte +++ b/web/src/lib/components/user-settings-page/change-password-settings.svelte @@ -23,7 +23,7 @@
-
+
diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 41f1e3b790..6bc94672df 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -3,6 +3,7 @@ import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk'; import { Button, modalManager, Text, toastManager } from '@immich/ui'; import { t } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; import DeviceCard from './device-card.svelte'; interface Props { @@ -50,33 +51,39 @@
- {#if currentSession} -
- - {$t('current_device')} - - -
- {/if} - {#if otherSessions.length > 0} -
- - {$t('other_devices')} - - {#each otherSessions as session, index (session.id)} - handleDelete(session)} /> - {#if index !== otherSessions.length - 1} -
- {/if} - {/each} -
+
+
+ {#if currentSession} +
+ + {$t('current_device')} + + +
+ {/if} + {#if otherSessions.length > 0} +
+ + {$t('other_devices')} + + {#each otherSessions as session, index (session.id)} + handleDelete(session)} /> + {#if index !== otherSessions.length - 1} +
+ {/if} + {/each} +
-
-
-
+
+
+
-
- +
+ +
+ {/if}
- {/if} +
diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte index 79d6ba6a1d..78e97ffd11 100644 --- a/web/src/lib/components/user-settings-page/download-settings.svelte +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -39,7 +39,7 @@
-
+
-
+
-
+
diff --git a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte index 5aeed92d41..5635cb0fa7 100644 --- a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte @@ -105,7 +105,7 @@
-
+
{#if $isPurchased}
diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte index 423a581e19..817c8933a6 100644 --- a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -65,7 +65,7 @@ {/snippet} -
+
{$t('photos_and_videos')} diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 081fb19e75..d0e61933d6 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -171,7 +171,7 @@ {asset.originalFileName} diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 6996d939d5..36047d4690 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -5,6 +5,14 @@ import type { ZoomImageWheelState } from '@zoom-image/core'; const isShowDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); +const createDefaultZoomState = (): ZoomImageWheelState => ({ + currentRotation: 0, + currentZoom: 1, + enable: true, + currentPositionX: 0, + currentPositionY: 0, +}); + export type Events = { Zoom: []; ZoomChange: [ZoomImageWheelState]; @@ -12,13 +20,7 @@ export type Events = { }; export class AssetViewerManager extends BaseEventManager { - #zoomState = $state({ - currentRotation: 0, - currentZoom: 1, - enable: true, - currentPositionX: 0, - currentPositionY: 0, - }); + #zoomState = $state(createDefaultZoomState()); imgRef = $state(); isShowActivityPanel = $state(false); @@ -67,6 +69,10 @@ export class AssetViewerManager extends BaseEventManager { this.#zoomState = state; } + resetZoomState() { + this.zoomState = createDefaultZoomState(); + } + toggleActivityPanel() { this.closeDetailPanel(); this.isShowActivityPanel = !this.isShowActivityPanel; diff --git a/web/src/lib/managers/edit/edit-manager.svelte.ts b/web/src/lib/managers/edit/edit-manager.svelte.ts index 8adfbe1f61..be74c6ead8 100644 --- a/web/src/lib/managers/edit/edit-manager.svelte.ts +++ b/web/src/lib/managers/edit/edit-manager.svelte.ts @@ -15,6 +15,7 @@ export interface EditToolManager { onDeactivate: () => void; resetAllChanges: () => Promise; hasChanges: boolean; + canReset: boolean; edits: EditAction[]; } @@ -41,19 +42,22 @@ export class EditManager { currentAsset = $state(null); selectedTool = $state(null); - hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges)); // used to disable multiple confirm dialogs and mouse events while one is open isShowingConfirmDialog = $state(false); isApplyingEdits = $state(false); hasAppliedEdits = $state(false); + hasUnsavedChanges = $derived(this.tools.some((t) => t.manager.hasChanges) && !this.hasAppliedEdits); + canReset = $derived(this.tools.some((t) => t.manager.canReset)); + async closeConfirm(): Promise { // Prevent multiple dialogs (usually happens with rapid escape key presses) if (this.isShowingConfirmDialog) { return false; } - if (!this.hasChanges || this.hasAppliedEdits) { + + if (!this.hasUnsavedChanges) { return true; } diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 7673889185..341eb3aa88 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -38,7 +38,8 @@ type RegionConvertParams = { }; class TransformManager implements EditToolManager { - hasChanges: boolean = $derived.by(() => this.checkEdits()); + canReset: boolean = $derived.by(() => this.checkEdits()); + hasChanges: boolean = $state(false); darkenLevel = $state(0.65); isInteracting = $state(false); @@ -56,7 +57,7 @@ class TransformManager implements EditToolManager { cropAspectRatio = $state('free'); originalImageSize = $state({ width: 1000, height: 1000 }); region = $state({ x: 0, y: 0, width: 100, height: 100 }); - preveiwImgSize = $derived({ + previewImageSize = $derived({ width: this.cropImageSize.width * this.cropImageScale, height: this.cropImageSize.height * this.cropImageScale, }); @@ -73,6 +74,7 @@ class TransformManager implements EditToolManager { edits = $derived.by(() => this.getEdits()); setAspectRatio(aspectRatio: string) { + this.hasChanges = true; this.cropAspectRatio = aspectRatio; if (!this.imgElement || !this.cropAreaEl) { @@ -88,8 +90,8 @@ class TransformManager implements EditToolManager { checkEdits() { return ( - Math.abs(this.preveiwImgSize.width - this.region.width) > 2 || - Math.abs(this.preveiwImgSize.height - this.region.height) > 2 || + Math.abs(this.previewImageSize.width - this.region.width) > 2 || + Math.abs(this.previewImageSize.height - this.region.height) > 2 || this.mirrorHorizontal || this.mirrorVertical || this.normalizedRotation !== 0 @@ -98,8 +100,8 @@ class TransformManager implements EditToolManager { checkCropEdits() { return ( - Math.abs(this.preveiwImgSize.width - this.region.width) > 2 || - Math.abs(this.preveiwImgSize.height - this.region.height) > 2 + Math.abs(this.previewImageSize.width - this.region.width) > 2 || + Math.abs(this.previewImageSize.height - this.region.height) > 2 ); } @@ -221,6 +223,10 @@ class TransformManager implements EditToolManager { this.dragOffset = { x: 0, y: 0 }; this.resizeSide = ''; this.imgElement = null; + if (this.cropAreaEl) { + this.cropAreaEl.style.cursor = ''; + } + document.body.style.cursor = ''; this.cropAreaEl = null; this.isDragging = false; this.overlayEl = null; @@ -232,9 +238,12 @@ class TransformManager implements EditToolManager { this.originalImageSize = { width: 1000, height: 1000 }; this.cropImageScale = 1; this.cropAspectRatio = 'free'; + this.hasChanges = false; } mirror(axis: 'horizontal' | 'vertical') { + this.hasChanges = true; + if (this.imageRotation % 180 !== 0) { axis = axis === 'horizontal' ? 'vertical' : 'horizontal'; } @@ -247,6 +256,8 @@ class TransformManager implements EditToolManager { } async rotate(angle: number) { + this.hasChanges = true; + this.imageRotation += angle; await tick(); this.onImageLoad(); @@ -760,6 +771,7 @@ class TransformManager implements EditToolManager { return; } + this.hasChanges = true; const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width)); const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height)); @@ -781,6 +793,7 @@ class TransformManager implements EditToolManager { } this.fadeOverlay(false); + this.hasChanges = true; const { x, y, width, height } = crop; const minSize = 50; let newRegion = { ...crop }; diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 4825dbc93b..4093413d1a 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -37,6 +37,7 @@ export type Events = { AssetsArchive: [string[]]; AssetsDelete: [string[]]; AssetEditsApplied: [string]; + AssetsTag: [string[]]; AlbumAddAssets: []; AlbumUpdate: [AlbumResponseDto]; diff --git a/web/src/lib/managers/theme-manager.svelte.ts b/web/src/lib/managers/theme-manager.svelte.ts index 26c3fe31d5..0ce0172c1a 100644 --- a/web/src/lib/managers/theme-manager.svelte.ts +++ b/web/src/lib/managers/theme-manager.svelte.ts @@ -2,7 +2,7 @@ import { browser } from '$app/environment'; import { Theme } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { PersistedLocalStorage } from '$lib/utils/persisted'; -import { theme as uiTheme, type Theme as UiTheme } from '@immich/ui'; +import { onThemeChange as onUiThemeChange, theme as uiTheme, type Theme as UiTheme } from '@immich/ui'; export interface ThemeSetting { value: Theme; @@ -55,15 +55,14 @@ class ThemeManager { } #onAppInit() { - globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener( - 'change', - () => { - if (this.theme.system) { - this.#update('system'); - } - }, - { passive: true }, - ); + const syncSystemTheme = () => { + this.#update(this.theme.system ? 'system' : this.theme.value); + }; + + syncSystemTheme(); + globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncSystemTheme, { + passive: true, + }); } #update(value: Theme | 'system') { @@ -75,6 +74,7 @@ class ThemeManager { this.#theme.current = theme; uiTheme.value = theme.value as unknown as UiTheme; + onUiThemeChange(); eventManager.emit('ThemeChange', theme); } diff --git a/web/src/lib/modals/AssetChangeDateModal.svelte b/web/src/lib/modals/AssetChangeDateModal.svelte index e94f1f7afc..ec4cb0f077 100644 --- a/web/src/lib/modals/AssetChangeDateModal.svelte +++ b/web/src/lib/modals/AssetChangeDateModal.svelte @@ -59,12 +59,7 @@ size="small" > - + {#if timezoneInput}
diff --git a/web/src/lib/modals/AssetSelectionChangeDateModal.svelte b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte index e60e4cb8a5..6f0be03f4d 100644 --- a/web/src/lib/modals/AssetSelectionChangeDateModal.svelte +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte @@ -77,11 +77,7 @@ {#if showRelative} - + {:else} diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index e541e24b60..c0c7f8b10a 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -1,4 +1,5 @@ - + {page.data.meta?.title || 'Web'} - Immich diff --git a/web/src/routes/admin/users/[id]/+layout.svelte b/web/src/routes/admin/users/[id]/+layout.svelte index 61fd184303..db86d05e72 100644 --- a/web/src/routes/admin/users/[id]/+layout.svelte +++ b/web/src/routes/admin/users/[id]/+layout.svelte @@ -1,5 +1,5 @@