+
+
+
+Configuration of OAuth in Immich System Settings
+
+| Setting | Value |
+| ---------------------------- | ----------------------------------------------------- |
+| Issuer URL | `https://
diff --git a/docs/docs/features/supported-formats.md b/docs/docs/features/supported-formats.md
index 4c4ac6039a..86ac264cc3 100644
--- a/docs/docs/features/supported-formats.md
+++ b/docs/docs/features/supported-formats.md
@@ -28,17 +28,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
## Video formats
-| Format | Extension(s) | Supported? | Notes |
-| :---------- | :-------------------- | :----------------: | :---- |
-| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
-| `AVI` | `.avi` | :white_check_mark: | |
-| `FLV` | `.flv` | :white_check_mark: | |
-| `M4V` | `.m4v` | :white_check_mark: | |
-| `MATROSKA` | `.mkv` | :white_check_mark: | |
-| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
-| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
-| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
-| `MXF` | `.mxf` | :white_check_mark: | |
-| `QUICKTIME` | `.mov` | :white_check_mark: | |
-| `WEBM` | `.webm` | :white_check_mark: | |
-| `WMV` | `.wmv` | :white_check_mark: | |
+| Format | Extension(s) | Supported? | Notes |
+| :---------- | :-------------------------- | :----------------: | :---- |
+| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
+| `AVI` | `.avi` | :white_check_mark: | |
+| `FLV` | `.flv` | :white_check_mark: | |
+| `M4V` | `.m4v` | :white_check_mark: | |
+| `MATROSKA` | `.mkv` | :white_check_mark: | |
+| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
+| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
+| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
+| `MXF` | `.mxf` | :white_check_mark: | |
+| `QUICKTIME` | `.mov` | :white_check_mark: | |
+| `WEBM` | `.webm` | :white_check_mark: | |
+| `WMV` | `.wmv` | :white_check_mark: | |
diff --git a/docs/docs/guides/custom-map-styles.md b/docs/docs/guides/custom-map-styles.md
index 1a61afc324..ac693c16ba 100644
--- a/docs/docs/guides/custom-map-styles.md
+++ b/docs/docs/guides/custom-map-styles.md
@@ -3,8 +3,8 @@
You may decide that you'd like to modify the style document which is used to
draw the maps in Immich. In addition to visual customization, this also allows
you to pick your own map tile provider instead of the default one. The default
-`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
-and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
+`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
+and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
can be used as a basis for creating your own style.
There are several sources for already-made `style.json` map themes, as well as
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index e9e3bb032c..41068dee97 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -29,22 +29,23 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
-| Variable | Description | Default | Containers | Workers |
-| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
-| `TZ` | Timezone | \*1 | server | microservices |
-| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
-| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
-| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
-| `IMMICH_MEDIA_LOCATION` | Media location inside the container â ī¸**You probably shouldn't set this**\*2â ī¸ | `/data` | server | api, microservices |
-| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
-| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
-| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
-| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
-| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
-| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
-| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
-| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
-| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
+| Variable | Description | Default | Containers | Workers |
+| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
+| `TZ` | Timezone | \*1 | server | microservices |
+| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
+| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
+| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
+| `IMMICH_MEDIA_LOCATION` | Media location inside the container â ī¸**You probably shouldn't set this**\*2â ī¸ | `/data` | server | api, microservices |
+| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
+| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api, microservices |
+| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
+| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
+| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
+| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
+| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
+| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
+| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
+| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md
index ee5db45c9a..66f3033a43 100644
--- a/docs/docs/install/requirements.md
+++ b/docs/docs/install/requirements.md
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
## Hardware
-- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
+- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
If you still want to try to use a non-Linux OS, you can set it up as follows:
@@ -19,6 +19,10 @@ Hardware and software requirements for Immich:
If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 6GB, recommended 8GB.
- **CPU**: Minimum 2 cores, recommended 4 cores.
+ - Immich runs on the `amd64` and `arm64` platforms.
+ Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
+ Most CPUs released since ~2012 support this microarchitecture.
+ If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
@@ -45,7 +49,7 @@ Immich requires [**Docker**](https://docs.docker.com/get-started/get-docker/) wi
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
:::note
-Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer supported by Immich.
+Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
:::
### Special requirements for Windows users
diff --git a/docs/package.json b/docs/package.json
index 60a6dccf87..f976791279 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -30,17 +30,17 @@
"postcss": "^8.4.25",
"prism-react-renderer": "^2.3.1",
"raw-loader": "^4.0.2",
- "react": "^18.0.0",
- "react-dom": "^18.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
"tailwindcss": "^3.2.4",
"url": "^0.11.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "~3.9.0",
- "@docusaurus/tsconfig": "^3.7.0",
+ "@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.7.4",
- "typescript": "^5.1.6"
+ "typescript": "^6.0.0"
},
"browserslist": {
"production": [
@@ -58,6 +58,6 @@
"node": ">=20"
},
"volta": {
- "node": "24.13.1"
+ "node": "24.14.1"
}
}
diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json
index 83206fefee..72e5487c9a 100644
--- a/docs/static/archived-versions.json
+++ b/docs/static/archived-versions.json
@@ -1,7 +1,11 @@
[
{
- "label": "v2.6.0",
- "url": "https://docs.v2.6.0.archive.immich.app"
+ "label": "v2.7.3",
+ "url": "https://docs.v2.7.3.archive.immich.app"
+ },
+ {
+ "label": "v2.6.3",
+ "url": "https://docs.v2.6.3.archive.immich.app"
},
{
"label": "v2.5.6",
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
index 674c46e46d..a6ba1bd9dd 100644
--- a/docs/tsconfig.json
+++ b/docs/tsconfig.json
@@ -1,8 +1,4 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
- "extends": "@docusaurus/tsconfig",
-
- "compilerOptions": {
- "baseUrl": "."
- }
+ "extends": "@docusaurus/tsconfig"
}
diff --git a/e2e/.nvmrc b/e2e/.nvmrc
index 32f8c50de0..8e35034890 100644
--- a/e2e/.nvmrc
+++ b/e2e/.nvmrc
@@ -1 +1 @@
-24.13.1
+24.14.1
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index 957de4698e..c8a3b975d4 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
- image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
+ image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
healthcheck:
test: redis-cli ping || exit 1
diff --git a/e2e/package.json b/e2e/package.json
index 1220d91418..a354ea5b64 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
- "version": "2.6.0",
+ "version": "2.7.3",
"description": "",
"main": "index.js",
"type": "module",
@@ -32,15 +32,15 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
- "@types/node": "^24.11.0",
+ "@types/node": "^24.12.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
- "@types/supertest": "^6.0.2",
+ "@types/supertest": "^7.0.0",
"dotenv": "^17.2.3",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
- "eslint-plugin-unicorn": "^63.0.0",
+ "eslint-plugin-unicorn": "^64.0.0",
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
@@ -51,13 +51,13 @@
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
- "typescript": "^5.3.3",
+ "typescript": "^6.0.0",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.0"
},
"volta": {
- "node": "24.13.1"
+ "node": "24.14.1"
}
}
diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts
new file mode 100644
index 0000000000..d6d0ec1394
--- /dev/null
+++ b/e2e/src/api/specs/duplicate.e2e-spec.ts
@@ -0,0 +1,651 @@
+import { LoginResponseDto } from '@immich/sdk';
+import { createUserDto, uuidDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { app, utils } from 'src/utils';
+import request from 'supertest';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+describe('/duplicates', () => {
+ let admin: LoginResponseDto;
+ let user1: LoginResponseDto;
+ let user2: LoginResponseDto;
+
+ beforeAll(async () => {
+ await utils.resetDatabase();
+
+ admin = await utils.adminSetup();
+
+ [user1, user2] = await Promise.all([
+ utils.userSetup(admin.accessToken, createUserDto.user1),
+ utils.userSetup(admin.accessToken, createUserDto.user2),
+ ]);
+ });
+
+ beforeEach(async () => {
+ // Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
+ // Note: We don't reset users since they're set up once in beforeAll
+ // Stack must be reset before asset due to foreign key constraint
+ await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
+ });
+
+ describe('GET /duplicates', () => {
+ it('should return empty array when no duplicates', async () => {
+ const { status, body } = await request(app)
+ .get('/duplicates')
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual([]);
+ });
+
+ it('should return duplicate groups with suggestedKeepAssetIds', async () => {
+ // Create assets with different file sizes for duplicate detection
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Manually set duplicateId on both assets to create a duplicate group
+ const duplicateId = '00000000-0000-4000-8000-000000000001';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .get('/duplicates')
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual([
+ {
+ duplicateId,
+ assets: expect.arrayContaining([
+ expect.objectContaining({ id: asset1.id }),
+ expect.objectContaining({ id: asset2.id }),
+ ]),
+ suggestedKeepAssetIds: expect.any(Array),
+ },
+ ]);
+ expect(body[0].suggestedKeepAssetIds.length).toBe(1);
+ });
+ });
+
+ describe('POST /duplicates/resolve', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .send({
+ groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
+ });
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should return failure for non-existent duplicate group', async () => {
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ status: 'COMPLETED',
+ results: [
+ {
+ duplicateId: uuidDto.dummy,
+ status: 'FAILED',
+ reason: expect.stringContaining('not found or access denied'),
+ },
+ ],
+ });
+ });
+
+ it('should resolve duplicate group with keepers', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000002';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ status: 'COMPLETED',
+ results: [
+ {
+ duplicateId,
+ status: 'SUCCESS',
+ },
+ ],
+ });
+
+ // Verify side effects: duplicateId cleared on kept asset
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.duplicateId).toBeNull();
+
+ // Verify side effects: trashed asset is trashed and duplicateId cleared
+ const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
+ expect(trashedAsset.isTrashed).toBe(true);
+ expect(trashedAsset.duplicateId).toBeNull();
+ });
+
+ it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000003';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('disjoint');
+ });
+
+ it('should require keepAssetIds when partially trashing', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000004';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('must cover all assets');
+ });
+
+ it('should reject partial resolution (not all assets covered)', async () => {
+ const [asset1, asset2, asset3] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000010';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('must cover all assets');
+ });
+
+ it('should reject asset not in duplicate group', async () => {
+ const [asset1, asset2, outsideAsset] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000011';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('not a member of duplicate group');
+ });
+
+ it('should allow trash-all without keepers', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000012';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ status: 'COMPLETED',
+ results: [
+ {
+ duplicateId,
+ status: 'SUCCESS',
+ },
+ ],
+ });
+
+ // Verify both assets are trashed
+ const [asset1Info, asset2Info] = await Promise.all([
+ utils.getAssetInfo(user1.accessToken, asset1.id),
+ utils.getAssetInfo(user1.accessToken, asset2.id),
+ ]);
+
+ expect(asset1Info.isTrashed).toBe(true);
+ expect(asset1Info.duplicateId).toBeNull();
+ expect(asset2Info.isTrashed).toBe(true);
+ expect(asset2Info.duplicateId).toBeNull();
+ });
+
+ it('should reject cross-user duplicate group access', async () => {
+ const asset1 = await utils.createAsset(user1.accessToken);
+ const asset2 = await utils.createAsset(user2.accessToken);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000013';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
+
+ // User1 tries to resolve a group containing user2's asset
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('not a member of duplicate group');
+ });
+
+ it('should synchronize favorites when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Mark one asset as favorite
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], isFavorite: true });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000020';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify favorite was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.isFavorite).toBe(true);
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize visibility when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Archive one asset
+ await utils.archiveAssets(user1.accessToken, [asset2.id]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000021';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify visibility was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.visibility).toBe('archive');
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize rating when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Set rating on one asset
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], rating: 5 });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000022';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify rating was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.exifInfo?.rating).toBe(5);
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize description when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Set description on one asset
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], description: 'Test description for duplicate' });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000023';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify description was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize location when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Set location on one asset
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000024';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify location was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
+ expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize albums when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Create albums and add assets to different albums
+ const album1 = await utils.createAlbum(user1.accessToken, {
+ albumName: 'Album 1',
+ assetIds: [asset1.id],
+ });
+ const album2 = await utils.createAlbum(user1.accessToken, {
+ albumName: 'Album 2',
+ assetIds: [asset2.id],
+ });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000025';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify keeper is now in both albums
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.duplicateId).toBeNull();
+
+ // Check albums directly
+ const { status: album1Status, body: album1Body } = await request(app)
+ .get(`/albums/${album1.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+ const { status: album2Status, body: album2Body } = await request(app)
+ .get(`/albums/${album2.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(album1Status).toBe(200);
+ expect(album2Status).toBe(200);
+ expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
+ expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
+ });
+
+ it('should synchronize tags when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Wait for metadata extraction to complete before adding tags
+ // Otherwise, metadata jobs will race and overwrite our tags
+ await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+ // Create tags and tag assets differently
+ const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
+ await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
+ await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000026';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify keeper has both tags
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.duplicateId).toBeNull();
+ expect(keptAsset.tags).toBeDefined();
+ const tagIds = keptAsset.tags?.map((t) => t.id) || [];
+ expect(tagIds).toContain(tags[0].id);
+ expect(tagIds).toContain(tags[1].id);
+ });
+
+ it('should handle batch resolve with mixed success and failure', async () => {
+ // Create first group that will succeed
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+ const duplicateId1 = '00000000-0000-4000-8000-000000000027';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
+
+ // Create second group with non-existent duplicate ID (will fail)
+ const fakeId = '00000000-0000-4000-8000-000000000099';
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [
+ { duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
+ { duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
+ ],
+ });
+
+ expect(status).toBe(200);
+ expect(body.status).toBe('COMPLETED');
+ expect(body.results).toHaveLength(2);
+
+ // First group should succeed
+ expect(body.results[0].duplicateId).toBe(duplicateId1);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Second group should fail
+ expect(body.results[1].duplicateId).toBe(fakeId);
+ expect(body.results[1].status).toBe('FAILED');
+ expect(body.results[1].reason).toContain('not found or access denied');
+
+ // Verify first group was actually resolved despite second failure
+ const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(asset1Info.duplicateId).toBeNull();
+ const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
+ expect(asset2Info.isTrashed).toBe(true);
+ });
+
+ it('should trash assets when trash is enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000028';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ // Ensure trash is enabled (default)
+ const config = await utils.getSystemConfig(admin.accessToken);
+ expect(config.trash.enabled).toBe(true);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify asset is trashed (not deleted)
+ const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
+ expect(trashedAsset.isTrashed).toBe(true);
+ });
+
+ it('should delete assets when trash is disabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000029';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ // Disable trash
+ await request(app)
+ .put('/system-config')
+ .set('Authorization', `Bearer ${admin.accessToken}`)
+ .send({
+ trash: { enabled: false, days: 30 },
+ });
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Asset should be marked as deleted (force delete)
+ const { status: getStatus } = await request(app)
+ .get(`/assets/${asset2.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ // Asset should still be accessible (soft deleted) but marked as deleted
+ expect(getStatus).toBe(200);
+
+ // Re-enable trash for other tests
+ await utils.resetAdminConfig(admin.accessToken);
+ });
+ });
+});
diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts
index 9e311c896d..1e03ad6d24 100644
--- a/e2e/src/fixtures.ts
+++ b/e2e/src/fixtures.ts
@@ -2,6 +2,8 @@ export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
+ dummy: '00000000-0000-4000-a000-000000000001',
+ dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {
diff --git a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts
index 2b0f6ae61a..b69bd099ed 100644
--- a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts
+++ b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts
@@ -10,7 +10,9 @@ describe('/admin/database-backups', () => {
beforeAll(async () => {
await utils.resetDatabase();
- admin = await utils.adminSetup();
+ admin = await utils.adminSetup({
+ onboarding: false,
+ });
await utils.resetBackups(admin.accessToken);
});
@@ -94,7 +96,9 @@ describe('/admin/database-backups', () => {
({ status, body }) => status === 200 && !body.maintenanceMode,
);
- admin = await utils.adminSetup();
+ admin = await utils.adminSetup({
+ onboarding: false,
+ });
});
it.sequential('should not work when the server is configured', async () => {
diff --git a/e2e/src/specs/server/api/album.e2e-spec.ts b/e2e/src/specs/server/api/album.e2e-spec.ts
index c4f06edd93..a9e90940ab 100644
--- a/e2e/src/specs/server/api/album.e2e-spec.ts
+++ b/e2e/src/specs/server/api/album.e2e-spec.ts
@@ -524,14 +524,19 @@ describe('/albums', () => {
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
});
- it('should not be able to update as an editor', async () => {
+ it('should be able to update as an editor', async () => {
const { status, body } = await request(app)
.patch(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ albumName: 'New album name' });
- expect(status).toBe(400);
- expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
+ expect(status).toBe(200);
+ expect(body).toEqual(
+ expect.objectContaining({
+ id: user1Albums[0].id,
+ albumName: 'New album name',
+ }),
+ );
});
});
diff --git a/e2e/src/specs/web/album.e2e-spec.ts b/e2e/src/specs/web/album.e2e-spec.ts
index 953c7d00ae..cd8bb87582 100644
--- a/e2e/src/specs/web/album.e2e-spec.ts
+++ b/e2e/src/specs/web/album.e2e-spec.ts
@@ -1,6 +1,7 @@
import { LoginResponseDto } from '@immich/sdk';
-import { test } from '@playwright/test';
-import { utils } from 'src/utils';
+import { expect, test } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { testAssetDir, utils } from 'src/utils';
test.describe('Album', () => {
let admin: LoginResponseDto;
@@ -22,4 +23,41 @@ test.describe('Album', () => {
await page.reload();
await page.getByRole('button', { name: 'Select photos' }).waitFor();
});
+
+ test('should keep map view open after viewing an asset from the map and going back', async ({ context, page }) => {
+ await utils.setAuthCookies(context, admin.accessToken);
+
+ const imagePath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
+ const mapAsset = await utils.createAsset(admin.accessToken, {
+ assetData: {
+ bytes: readFileSync(imagePath),
+ filename: 'thompson-springs.jpg',
+ },
+ });
+
+ await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+ const mapAlbum = await utils.createAlbum(admin.accessToken, {
+ albumName: 'Map Test Album',
+ assetIds: [mapAsset.id],
+ });
+
+ await page.goto(`/albums/${mapAlbum.id}`);
+ const mapButton = page.getByRole('button', { name: 'Map' });
+ await expect(mapButton).toBeVisible();
+ await mapButton.click();
+
+ const mapModal = page.getByRole('dialog');
+ await expect(mapModal).toBeVisible();
+
+ const mapMarker = mapModal.getByRole('img', { name: /Map marker/i }).first();
+ await expect(mapMarker).toBeVisible();
+ await mapMarker.click();
+
+ await page.waitForSelector('#immich-asset-viewer');
+ await page.getByRole('button', { name: 'Go back' }).click();
+
+ await expect(page.locator('#immich-asset-viewer')).not.toBeVisible();
+ await expect(mapModal).toBeVisible();
+ });
});
diff --git a/e2e/src/specs/web/duplicates.e2e-spec.ts b/e2e/src/specs/web/duplicates.e2e-spec.ts
new file mode 100644
index 0000000000..34f11cdf78
--- /dev/null
+++ b/e2e/src/specs/web/duplicates.e2e-spec.ts
@@ -0,0 +1,51 @@
+import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
+import { expect, test } from '@playwright/test';
+import crypto from 'node:crypto';
+import { asBearerAuth, utils } from 'src/utils';
+
+test.describe('Duplicates Utility', () => {
+ let admin: LoginResponseDto;
+ let firstAsset: AssetMediaResponseDto;
+ let secondAsset: AssetMediaResponseDto;
+
+ test.beforeAll(async () => {
+ utils.initSdk();
+ await utils.resetDatabase();
+ admin = await utils.adminSetup();
+ });
+
+ test.beforeEach(async ({ context }) => {
+ [firstAsset, secondAsset] = await Promise.all([
+ utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
+ utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
+ ]);
+
+ await updateAssets(
+ {
+ assetBulkUpdateDto: {
+ ids: [firstAsset.id, secondAsset.id],
+ duplicateId: crypto.randomUUID(),
+ },
+ },
+ { headers: asBearerAuth(admin.accessToken) },
+ );
+
+ await utils.setAuthCookies(context, admin.accessToken);
+ });
+
+ test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
+ await page.goto('/utilities/duplicates');
+ await page.getByRole('button', { name: 'View' }).first().click();
+ await page.waitForSelector('#immich-asset-viewer');
+
+ const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
+ const initialAssetId = getViewedAssetId();
+ expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
+
+ await page.keyboard.press('ArrowRight');
+ await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
+
+ await page.keyboard.press('ArrowLeft');
+ await expect.poll(getViewedAssetId).toBe(initialAssetId);
+ });
+});
diff --git a/e2e/src/ui/mock-network/base-network.ts b/e2e/src/ui/mock-network/base-network.ts
index f23202ca77..7c4aee59e3 100644
--- a/e2e/src/ui/mock-network/base-network.ts
+++ b/e2e/src/ui/mock-network/base-network.ts
@@ -1,5 +1,5 @@
import { BrowserContext } from '@playwright/test';
-import { playwrightHost } from 'playwright.config';
+import { playwrightHost } from 'src/../playwright.config';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
@@ -173,6 +173,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
'.mpeg',
'.mpg',
'.mts',
+ '.ts',
'.vob',
'.webm',
'.wmv',
diff --git a/e2e/src/ui/mock-network/ocr-network.ts b/e2e/src/ui/mock-network/ocr-network.ts
new file mode 100644
index 0000000000..3b1a2fe62e
--- /dev/null
+++ b/e2e/src/ui/mock-network/ocr-network.ts
@@ -0,0 +1,55 @@
+import { faker } from '@faker-js/faker';
+import type { AssetOcrResponseDto } from '@immich/sdk';
+import { BrowserContext } from '@playwright/test';
+
+export type MockOcrBox = {
+ text: string;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ x3: number;
+ y3: number;
+ x4: number;
+ y4: number;
+};
+
+export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
+ return boxes.map((box) => ({
+ id: faker.string.uuid(),
+ assetId,
+ x1: box.x1,
+ y1: box.y1,
+ x2: box.x2,
+ y2: box.y2,
+ x3: box.x3,
+ y3: box.y3,
+ x4: box.x4,
+ y4: box.y4,
+ boxScore: 0.95,
+ textScore: 0.9,
+ text: box.text,
+ }));
+};
+
+export const setupOcrMockApiRoutes = async (
+ context: BrowserContext,
+ ocrDataByAssetId: Map{label} āĻšāϞ⧠āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰā§āϰ āϏā§āĻā§āϰā§āĻ āϞā§āĻŦā§āϞ (Storage Label)",
+ "system_settings": "āϏāĻŋāϏā§āĻā§āĻŽ āϏā§āĻāĻŋāĻāϏ",
+ "tag_cleanup_job": "āĻā§āϝāĻžāĻ āĻŽā§āĻā§ āĻĢā§āϞāĻž",
+ "template_email_available_tags": "āĻāĻĒāύāĻŋ āĻāĻĒāύāĻžāϰ āĻā§āĻŽāĻĒā§āϞā§āĻā§ āύāĻŋāĻŽā§āύāϞāĻŋāĻāĻŋāϤ āĻā§āϰāĻŋāϝāĻŧā§āĻŦāϞāĻā§āϞ⧠āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰāϤ⧠āĻĒāĻžāϰā§āύ: {tags}",
+ "template_email_if_empty": "āĻā§āĻŽāĻĒā§āϞā§āĻāĻāĻŋ āĻāĻžāϞāĻŋ āĻĨāĻžāĻāϞ⧠āĻĄāĻŋāĻĢāϞā§āĻ āĻāĻŽā§āϞ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰāĻž āĻšāĻŦā§āĨ¤",
+ "template_email_invite_album": "āĻāύāĻāĻžāĻāĻ āĻ
ā§āϝāĻžāϞāĻŦāĻžāĻŽ āĻā§āĻŽāĻĒā§āϞā§āĻ",
+ "template_email_preview": "āĻĒā§āϰāĻŋāĻāĻŋāĻ",
+ "template_email_settings": "āĻāĻŽā§āĻāϞ āĻā§āĻŽāĻĒā§āϞā§āĻ",
+ "template_email_update_album": "āĻ
ā§āϝāĻžāϞāĻŦāĻžāĻŽ āĻā§āĻŽāĻĒā§āϞā§āĻ āĻāĻĒāĻĄā§āĻ āĻāϰā§āύ",
+ "template_email_welcome": "āϏā§āĻŦāĻžāĻāϤāĻŽ āĻāĻŽā§āĻāϞ āĻā§āĻŽāĻĒā§āϞā§āĻ",
+ "template_settings": "āύā§āĻāĻŋāĻĢāĻŋāĻā§āĻļāύ āĻā§āĻŽāĻĒā§āϞā§āĻ",
+ "template_settings_description": "āύā§āĻāĻŋāĻĢāĻŋāĻā§āĻļāύā§āϰ āĻāύā§āϝ āĻāĻžāϏā§āĻāĻŽ āĻā§āĻŽāĻĒā§āϞā§āĻ āĻĒāϰāĻŋāĻāĻžāϞāύāĻž āĻāϰā§āύ",
+ "theme_custom_css_settings": "āĻāĻžāϏā§āĻāĻŽ CSS",
+ "theme_custom_css_settings_description": "āĻā§āϝāĻžāϏāĻā§āĻĄāĻŋāĻ āϏā§āĻāĻžāĻāϞ āĻļā§āĻ āĻŦā§āϝāĻŦāĻšāĻžāϰ āĻāϰ⧠Immich āĻāϰ āĻĄāĻŋāĻāĻžāĻāύ āĻāĻžāϏā§āĻāĻŽāĻžāĻāĻ āĻāϰāĻž āϝāĻžāϝāĻŧāĨ¤",
+ "theme_settings": "āĻĨā§āĻŽ āϏā§āĻāĻŋāĻāϏ",
"theme_settings_description": "āĻāĻŽāĻŋāĻ (Immich) āĻāϝāĻŧā§āĻŦ āĻāύā§āĻāĻžāϰāĻĢā§āϏā§āϰ āĻāĻžāϏā§āĻāĻŽāĻžāĻāĻā§āĻļāύ āĻŽā§āϝāĻžāύā§āĻ āĻāϰā§āύ",
"thumbnail_generation_job": "āĻĨāĻžāĻŽā§āĻŦāύā§āĻāϞ āϤā§āϰāĻŋ āĻāϰā§āύ (Generate Thumbnails)",
"thumbnail_generation_job_description": "āĻĒā§āϰāϤāĻŋāĻāĻŋ āĻ
ā§āϝāĻžāϏā§āĻā§āϰ āĻāύā§āϝ āĻŦā§, āĻā§āĻ āĻāĻŦāĻ āĻŦā§āϞāĻžāϰ (āĻ
āϏā§āĻĒāώā§āĻ) āĻĨāĻžāĻŽā§āĻŦāύā§āĻāϞ āϤā§āϰāĻŋ āĻāϰā§āύ, āϏā§āĻ āϏāĻžāĻĨā§ āĻĒā§āϰāϤāĻŋāĻāĻŋ āĻŦā§āϝāĻā§āϤāĻŋāϰ āĻāύā§āϝāĻ āĻĨāĻžāĻŽā§āĻŦāύā§āĻāϞ āϤā§āϰāĻŋ āĻāϰā§āύāĨ¤",
@@ -334,8 +364,281 @@
"transcoding_acceleration_vaapi": "VA-API (āĻāĻŋāĻĄāĻŋāĻ āĻ
ā§āϝāĻžāĻā§āϏāĻŋāϞāĻžāϰā§āĻļāύ āĻāĻĒāĻŋāĻāĻ)",
"transcoding_accepted_audio_codecs": "āĻā§āϰāĻšāĻŖāϝā§āĻā§āϝ āĻ
āĻĄāĻŋāĻ āĻā§āĻĄā§āĻāϏāĻŽā§āĻš (Accepted audio codecs)",
"transcoding_accepted_audio_codecs_description": "āĻā§āύ āĻ
āĻĄāĻŋāĻ āĻā§āĻĄā§āĻāĻā§āϞ⧠āĻā§āϰāĻžāύāϏāĻā§āĻĄ āĻāϰāĻžāϰ āĻĒā§āϰā§ā§āĻāύ āύā§āĻ āϤāĻž āύāĻŋāϰā§āĻŦāĻžāĻāύ āĻāϰā§āύāĨ¤ āĻāĻāĻŋ āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āύāĻŋāϰā§āĻĻāĻŋāώā§āĻ āĻā§āϰāĻžāύāϏāĻā§āĻĄ āĻĒāϞāĻŋāϏāĻŋāϰ (transcode policies) āĻāύā§āϝ āĻŦā§āϝāĻŦāĻšā§āϤ āĻšā§āĨ¤",
- "transcoding_accepted_containers": "āĻā§āϰāĻšāĻŖāϝā§āĻā§āϝ āĻāύā§āĻā§āĻāύāĻžāϰāϏāĻŽā§āĻš (Accepted containers)"
+ "transcoding_accepted_containers": "āĻā§āϰāĻšāĻŖāϝā§āĻā§āϝ āĻāύā§āĻā§āĻāύāĻžāϰāϏāĻŽā§āĻš (Accepted containers)",
+ "transcoding_accepted_containers_description": "āĻā§āύ āĻāύā§āĻā§āĻāύāĻžāϰ āĻĢāϰāĻŽā§āϝāĻžāĻāĻā§āϞā§āĻā§ MP4-āĻ āϰāĻŋāĻŽā§āĻā§āϏ āĻāϰāĻžāϰ āĻĒā§āϰāϝāĻŧā§āĻāύ āύā§āĻ āϤāĻž āύāĻŋāϰā§āĻŦāĻžāĻāύ āĻāϰā§āύāĨ¤ āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āύāĻŋāϰā§āĻĻāĻŋāώā§āĻ āĻā§āϰāĻžāύā§āϏāĻā§āĻĄ āĻĒāϞāĻŋāϏāĻŋāϰ āĻāύā§āϝ āĻŦā§āϝāĻŦāĻšā§āϤ āĻšāϝāĻŧāĨ¤",
+ "transcoding_accepted_video_codecs": "āϏāĻŽāϰā§āĻĨāĻŋāϤ āĻāĻŋāĻĄāĻŋāĻ āĻā§āĻĄā§āĻāĻā§āϞā§",
+ "transcoding_accepted_video_codecs_description": "āĻā§āύ āĻāĻŋāĻĄāĻŋāĻ āĻā§āĻĄā§āĻāĻā§āϞ⧠āĻā§āϰāĻžāύā§āϏāĻā§āĻĄ āĻāϰāĻžāϰ āĻĒā§āϰāϝāĻŧā§āĻāύ āύā§āĻ āϤāĻž āύāĻŋāϰā§āĻŦāĻžāĻāύ āĻāϰā§āύāĨ¤ āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āύāĻŋāϰā§āĻĻāĻŋāώā§āĻ āĻā§āϰāĻžāύā§āϏāĻā§āĻĄ āύā§āϤāĻŋāϰ āĻāύā§āϝ āĻŦā§āϝāĻŦāĻšā§āϤ āĻšāϝāĻŧāĨ¤",
+ "transcoding_advanced_options_description": "āĻŦā§āĻļāĻŋāϰāĻāĻžāĻ āĻŦā§āϝāĻŦāĻšāĻžāϰāĻāĻžāϰā§āϰ āĻĒāϰāĻŋāĻŦāϰā§āϤāύ āĻāϰāĻžāϰ āĻĒā§āϰā§ā§āĻāύ āύā§āĻ āĻāĻŽāύ āĻ
āĻĒāĻļāύāϏāĻŽā§āĻš",
+ "transcoding_audio_codec": "āĻ
āĻĄāĻŋāĻ āĻā§āĻĄā§āĻ",
+ "transcoding_audio_codec_description": "Opus āϏāϰā§āĻŦā§āĻā§āĻ āĻŽāĻžāύā§āϰ āĻ
āĻĒāĻļāύ, āϤāĻŦā§ āĻĒā§āϰā§āύ⧠āĻĄāĻŋāĻāĻžāĻāϏ āĻŦāĻž āϏāĻĢāĻāĻā§ā§āϝāĻžāϰā§āϰ āϏāĻžāĻĨā§ āĻāϰ āϏāĻžāĻŽāĻā§āĻāϏā§āϝ āĻāĻŽāĨ¤",
+ "transcoding_bitrate_description": "āϏāϰā§āĻŦā§āĻā§āĻ āĻŦāĻŋāĻāϰā§āĻā§āϰ āĻā§ā§ā§ āĻŦā§āĻļāĻŋ āĻŦāĻž āϏāĻŽāϰā§āĻĨāĻŋāϤ āĻĢāϰāĻŽā§āϝāĻžāĻā§ āύ⧠āĻāĻŽāύ āĻāĻŋāĻĄāĻŋāĻ",
+ "transcoding_codecs_learn_more": "āĻāĻāĻžāύ⧠āĻŦā§āϝāĻŦāĻšā§āϤ āĻĒāϰāĻŋāĻāĻžāώāĻž āϏāĻŽā§āĻĒāϰā§āĻā§ āĻāϰāĻ āĻāĻžāύāϤ⧠FFmpeg āĻĄāĻā§āĻŽā§āύā§āĻā§āĻļāύ āĻĻā§āĻā§āύ, {label} - ŅĐĩ ĐŧŅŅĐēа СйĐĩŅŅĐŗĐ°ĐŊĐŊŅ ĐēĐžŅиŅŅŅваŅа",
+ "storage_template_settings_description": "ĐĐĩŅŅваĐŊĐŊŅ ŅŅŅŅĐēŅŅŅĐžŅ ĐŋаĐŋĐžĐē Ņа ĐŊаСваĐŧи ŅаКĐģŅв виваĐŊŅаĐļĐĩĐŊиŅ
ĐĩĐģĐĩĐŧĐĩĐŊŅŅв",
+ "storage_template_user_label": "{label} â ŅĐĩ ĐŧŅŅĐēа ŅŅ
ОвиŅа ĐēĐžŅиŅŅŅваŅа",
"system_settings": "ХиŅŅĐĩĐŧĐŊŅ ĐŊаĐģаŅŅŅваĐŊĐŊŅ",
"tag_cleanup_job": "ĐŅиŅĐĩĐŊĐŊŅ ŅĐĩĐŗŅв",
- "template_email_available_tags": "Đи ĐŧĐžĐļĐĩŅĐĩ виĐēĐžŅиŅŅОвŅваŅи ĐŊаŅŅŅĐŋĐŊŅ ĐˇĐŧŅĐŊĐŊŅ Ņ ŅвОŅĐŧŅ ŅайĐģĐžĐŊŅ: {tags}",
- "template_email_if_empty": "Đ¯ĐēŅĐž ŅайĐģĐžĐŊ ĐŋĐžŅĐžĐļĐŊŅĐš, ĐąŅĐ´Đĩ виĐēĐžŅиŅŅаĐŊĐž ŅŅаĐŊдаŅŅĐŊиК ĐĩĐģĐĩĐēŅŅĐžĐŊĐŊиК ĐģиŅŅ.",
+ "template_email_available_tags": "Đи ĐŧĐžĐļĐĩŅĐĩ виĐēĐžŅиŅŅОвŅваŅи ŅаĐēŅ ĐˇĐŧŅĐŊĐŊŅ Ņ ŅвОŅĐŧŅ ŅайĐģĐžĐŊŅ: {tags}",
+ "template_email_if_empty": "Đ¯ĐēŅĐž ŅайĐģĐžĐŊ ĐŋĐžŅĐžĐļĐŊŅĐš, ĐąŅĐ´Đĩ виĐēĐžŅиŅŅаĐŊĐž ŅиĐŋОвиК ĐĩĐģĐĩĐēŅŅĐžĐŊĐŊиК ĐģиŅŅ.",
"template_email_invite_album": "ШайĐģĐžĐŊ СаĐŋŅĐžŅĐĩĐŊĐŊŅ Đ´Đž аĐģŅйОĐŧŅ",
- "template_email_preview": "ĐĐĩŅĐĩĐŗĐģŅĐ´",
+ "template_email_preview": "ĐĐžĐŋĐĩŅĐĩĐ´ĐŊŅĐš ĐŋĐĩŅĐĩĐŗĐģŅĐ´",
"template_email_settings": "ШайĐģĐžĐŊи ĐĩĐģĐĩĐēŅŅĐžĐŊĐŊиŅ
ĐģиŅŅŅв",
- "template_email_update_album": "ĐĐŊОвиŅи ŅайĐģĐžĐŊ аĐģŅйОĐŧŅ",
+ "template_email_update_album": "ШайĐģĐžĐŊ ĐžĐŊОвĐģĐĩĐŊĐŊŅ Đ°ĐģŅйОĐŧŅ",
"template_email_welcome": "ШайĐģĐžĐŊ вŅŅаĐģŅĐŊĐžĐŗĐž ĐĩĐģĐĩĐēŅŅĐžĐŊĐŊĐžĐŗĐž ĐģиŅŅа",
- "template_settings": "ШайĐģĐžĐŊи ĐŋОвŅĐ´ĐžĐŧĐģĐĩĐŊŅ",
- "template_settings_description": "ĐĐĩŅŅваŅи ŅайĐģĐžĐŊаĐŧи Đ´ĐģŅ ĐŋОвŅĐ´ĐžĐŧĐģĐĩĐŊŅ",
- "theme_custom_css_settings": "ĐĐģаŅĐŊиК CSS",
- "theme_custom_css_settings_description": "ĐаŅĐēадĐŊŅ ŅайĐģиŅŅ ŅŅиĐģŅв дОСвОĐģŅŅŅŅ ĐŊаŅŅŅĐžŅваŅи диСаКĐŊ Immich.",
- "theme_settings": "ĐаĐģаŅŅŅваĐŊĐŊŅ ŅĐĩĐŧи",
- "theme_settings_description": "ĐаĐģаŅŅŅваĐŊĐŊŅ ĐŋĐĩŅŅĐžĐŊаĐģŅСаŅŅŅ Đ˛ĐĩĐą-ŅĐŊŅĐĩŅŅĐĩĐšŅŅ Immich",
+ "template_settings": "ШайĐģĐžĐŊи ŅĐŋОвŅŅĐĩĐŊŅ",
+ "template_settings_description": "ĐĐĩŅŅваĐŊĐŊŅ Đ´ĐžĐ˛ŅĐģŅĐŊиĐŧи ŅайĐģĐžĐŊаĐŧи Đ´ĐģŅ ŅĐŋОвŅŅĐĩĐŊŅ",
+ "theme_custom_css_settings": "ĐОвŅĐģŅĐŊиК CSS",
+ "theme_custom_css_settings_description": "ĐаŅĐēадĐŊŅ ŅайĐģиŅŅ ŅŅиĐģŅв даŅŅŅ ĐˇĐŧĐžĐŗŅ ĐŊаĐģаŅŅОвŅваŅи диСаКĐŊ Immich.",
+ "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, ŅĐēа ĐąŅĐ´Đĩ вСаŅĐŧОдŅŅŅи С ваŅиĐŧ ĐŋŅиŅŅŅĐžŅĐŧ Đ´ĐģŅ ĐŋŅиŅĐēĐžŅĐĩĐŊĐŊŅ ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ. ĐĻĐĩ ĐŊаĐģаŅŅŅваĐŊĐŊŅ ĐŋŅаŅŅŅ Ņ \"ĐŊаКĐēŅаŅиŅ
ŅĐŧОваŅ
\" Ņ, в ŅĐ°ĐˇŅ ĐŊĐĩвдаŅŅ, ĐŋĐĩŅĐĩКдĐĩ ĐŊа ĐŋŅĐžĐŗŅаĐŧĐŊĐĩ ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ. ĐŅĐ´ŅŅиĐŧĐēа VP9 ĐŧĐžĐļĐĩ айО ĐŊĐĩ ĐŧĐžĐļĐĩ ĐŋŅаŅŅваŅи, СаĐģĐĩĐļĐŊĐž вŅĐ´ ваŅĐžĐŗĐž ОйĐģадĐŊаĐŊĐŊŅ.",
- "transcoding_acceleration_nvenc": "NVENC (виĐŧĐ°ĐŗĐ°Ņ ĐŗŅаŅŅŅĐŊĐžĐŗĐž ĐŋŅĐžŅĐĩŅĐžŅа NVIDIA)",
- "transcoding_acceleration_qsv": "ШвидĐēа ŅиĐŊŅ
ŅĐžĐŊŅСаŅŅŅ (ĐŋĐžŅŅŅĐąĐĩĐŊ ĐŋŅĐžŅĐĩŅĐžŅ Intel 7-ĐŗĐž ĐŋĐžĐēĐžĐģŅĐŊĐŊŅ Đ°ĐąĐž ĐŊОвŅŅĐžŅ Đ˛ĐĩŅŅŅŅ)",
- "transcoding_acceleration_rkmpp": "RKMPP (ŅŅĐģŅĐēи ĐŊа SOC Rockchip)",
+ "transcoding_acceleration_api_description": "API, ŅĐēиК ĐąŅĐ´Đĩ вСаŅĐŧОдŅŅŅи С ваŅиĐŧ ĐŋŅиŅŅŅĐžŅĐŧ Đ´ĐģŅ ĐŋŅиŅĐēĐžŅĐĩĐŊĐŊŅ ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ. ĐĻĐĩ ĐŊаĐģаŅŅŅваĐŊĐŊŅ ĐŊĐĩ ĐŗĐ°ŅаĐŊŅŅŅ ŅĐĩСŅĐģŅŅаŅ: Ņ ŅĐ°ĐˇŅ ĐŊĐĩвдаŅŅ ĐąŅĐ´Đĩ виĐēĐžŅиŅŅаĐŊĐž ĐŋŅĐžĐŗŅаĐŧĐŊĐĩ ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ. ĐŅĐ´ŅŅиĐŧĐēа VP9 ĐŧĐžĐļĐĩ ĐŋŅаŅŅваŅи айО ĐŊŅ, СаĐģĐĩĐļĐŊĐž вŅĐ´ ваŅĐžĐŗĐž ОйĐģадĐŊаĐŊĐŊŅ.",
+ "transcoding_acceleration_nvenc": "NVENC (ĐŋĐžŅŅĐĩĐąŅŅ ĐŗŅаŅŅŅĐŊĐžĐŗĐž ĐŋŅĐžŅĐĩŅĐžŅа NVIDIA)",
+ "transcoding_acceleration_qsv": "Intel Quick Sync (ĐŋĐžŅŅŅĐąĐĩĐŊ ĐŋŅĐžŅĐĩŅĐžŅ Intel 7-ĐŗĐž ĐŋĐžĐēĐžĐģŅĐŊĐŊŅ Đ°ĐąĐž ĐŊОвŅŅиК)",
+ "transcoding_acceleration_rkmpp": "RKMPP (ĐģиŅĐĩ ĐŊа SoC Rockchip)",
"transcoding_acceleration_vaapi": "VAAPI",
"transcoding_accepted_audio_codecs": "ĐŅиКĐŊŅŅŅ Đ°ŅĐ´ŅĐžĐēОдĐĩĐēи",
"transcoding_accepted_audio_codecs_description": "ĐийĐĩŅŅŅŅ Đ°ŅĐ´ŅĐžĐēОдĐĩĐēи, ŅĐēŅ ĐŊĐĩ ĐŋĐžŅŅĐĩĐąŅŅŅŅ ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ. ĐиĐēĐžŅиŅŅОвŅŅŅŅŅŅ ĐģиŅĐĩ Đ´ĐģŅ ĐŋĐĩвĐŊиŅ
ĐŋĐžĐģŅŅиĐē ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ.",
"transcoding_accepted_containers": "ĐŅиКĐŊŅŅŅ ĐēĐžĐŊŅĐĩĐšĐŊĐĩŅи",
- "transcoding_accepted_containers_description": "ĐийĐĩŅŅŅŅ, ŅĐēŅ ŅĐžŅĐŧаŅи ĐēĐžĐŊŅĐĩĐšĐŊĐĩŅŅв ĐŊĐĩ ĐŋĐžŅŅŅĐąĐŊĐž ĐŋĐĩŅĐĩŅвОŅŅваŅи в MP4. ĐиĐēĐžŅиŅŅОвŅŅŅŅŅŅ ĐģиŅĐĩ Đ´ĐģŅ ĐŋĐĩвĐŊиŅ
ĐŋĐžĐģŅŅиĐē ĐŋĐĩŅĐĩĐēОдŅваĐŊĐŊŅ.",
+ "transcoding_accepted_containers_description": "ĐийĐĩŅŅŅŅ, ŅĐēŅ ŅĐžŅĐŧаŅи ĐēĐžĐŊŅĐĩĐšĐŊĐĩŅŅв ĐŊĐĩ ĐŋĐžŅŅŅĐąĐŊĐž ĐŋĐĩŅĐĩĐŋаĐēŅваŅи в MP4. ĐиĐēĐžŅиŅŅОвŅŅŅŅŅŅ ĐģиŅĐĩ Đ´ĐģŅ ĐŋĐĩвĐŊиŅ
ĐŋĐžĐģŅŅиĐē ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ.",
"transcoding_accepted_video_codecs": "ĐŅиКĐŊŅŅŅ Đ˛ŅĐ´ĐĩĐžĐēОдĐĩĐēи",
"transcoding_accepted_video_codecs_description": "ĐийĐĩŅŅŅŅ Đ˛ŅĐ´ĐĩĐžĐēОдĐĩĐēи, ŅĐēŅ ĐŊĐĩ ĐŋĐžŅŅĐĩĐąŅŅŅŅ ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ. ĐиĐēĐžŅиŅŅОвŅŅŅŅŅŅ ĐģиŅĐĩ Đ´ĐģŅ ĐŋĐĩвĐŊиŅ
ĐŋĐžĐģŅŅиĐē ŅŅаĐŊŅĐēОдŅваĐŊĐŊŅ.",
"transcoding_advanced_options_description": "ĐаŅаĐŧĐĩŅŅи, ŅĐēŅ ĐąŅĐģŅŅĐžŅŅŅ ĐēĐžŅиŅŅŅваŅŅв ĐŊĐĩ ĐŋĐžŅŅŅĐąĐŊĐž СĐŧŅĐŊŅваŅи",
"transcoding_audio_codec": "ĐŅĐ´ŅĐžĐēОдĐĩĐē",
- "transcoding_audio_codec_description": "Opus - ŅĐĩ ĐžĐŋŅŅŅ ĐŊаКвиŅĐžŅ ŅĐēĐžŅŅŅ, аĐģĐĩ ĐŧĐĩĐŊŅĐĩ ŅŅĐŧŅŅĐŊа ĐˇŅ ŅŅаŅиĐŧи ĐŋŅиŅŅŅĐžŅĐŧи айО ĐŋŅĐžĐŗŅаĐŧĐŊиĐŧ СайĐĩСĐŋĐĩŅĐĩĐŊĐŊŅĐŧ.",
+ "transcoding_audio_codec_description": "Opus â ваŅŅаĐŊŅ ĐŊаКвиŅĐžŅ ŅĐēĐžŅŅŅ, аĐģĐĩ ĐŧĐ°Ņ ĐŊиĐļŅŅ ŅŅĐŧŅŅĐŊŅŅŅŅ ĐˇŅ ŅŅаŅиĐŧи ĐŋŅиŅŅŅĐžŅĐŧи айО ĐŋŅĐžĐŗŅаĐŧаĐŧи.",
"transcoding_bitrate_description": "ĐŅĐ´ĐĩĐž С ĐąŅŅŅĐĩĐšŅĐžĐŧ виŅĐĩ ĐŧаĐēŅиĐŧаĐģŅĐŊĐžĐŗĐž айО ĐŊĐĩ в ĐŋŅиКĐŊŅŅĐžĐŧŅ ŅĐžŅĐŧаŅŅ",
- "transcoding_codecs_learn_more": "ĐĐģŅ ĐžŅŅиĐŧаĐŊĐŊŅ Đ´ĐžĐ´Đ°ŅĐēĐžĐ˛ĐžŅ ŅĐŊŅĐžŅĐŧаŅŅŅ ĐŋŅĐž ŅĐĩŅĐŧŅĐŊĐžĐģĐžĐŗŅŅ, ŅĐž виĐēĐžŅиŅŅОвŅŅŅŅŅŅ ŅŅŅ, СвĐĩŅŅаКŅĐĩŅŅ Đ´Đž Đ´ĐžĐēŅĐŧĐĩĐŊŅаŅŅŅ FFmpeg Đ´ĐģŅ ĐēОдĐĩĐēŅв {label}ä¸ēč¯Ĩ፿ˇįå卿 įž",
"system_settings": "įŗģįģ莞įŊŽ",
"tag_cleanup_job": "æ įžæ¸
į",
@@ -351,16 +351,16 @@
"template_settings": "éįĨæ¨Ąæŋ",
"template_settings_description": "įŽĄįéįĨįčĒåŽäšæ¨Ąæŋ",
"theme_custom_css_settings": "čĒåŽäš CSS",
- "theme_custom_css_settings_description": "CSS å
莸čĒåŽäš Immich įéĸ莞莥ã",
+ "theme_custom_css_settings_description": "äŊŋį¨CSSčĒåŽäšImmichįéĸ莞莥ã",
"theme_settings": "ä¸ģéĸ莞įŊŽ",
"theme_settings_description": "čĒåŽäš Immich Web įéĸ",
"thumbnail_generation_job": "įæįŧŠįĨåž",
- "thumbnail_generation_job_description": "ä¸ēæ¯ä¸Ēčĩäē§įæä¸åå°ē寸įįŧŠįĨåžīŧåšļä¸ēæ¯ä¸ĒäēēįŠįæįŧŠįĨåž",
+ "thumbnail_generation_job_description": "ä¸ēæ¯ä¸Ēį
§į/č§éĸįæä¸åå°ē寸įįŧŠįĨåžīŧåšļä¸ēæ¯ä¸ĒäēēįŠįæįŧŠįĨåž",
"transcoding_acceleration_api": "įĄŦäģļå é API",
"transcoding_acceleration_api_description": "į¨äēä¸čŽžå¤äē¤äēäģĨå éčŊŦį į APIãč¯Ĩ莞įŊŽéį¨âå°Ŋåčä¸ēâįįĨīŧčĨįĄŦäģļå éå¤ąč´Ĩīŧįŗģįģå°čĒå¨åéå°čŊ¯äģļčŊŦį ãVP9 įŧį ῝ææ
åĩååŗä翍įįĄŦäģļé
įŊŽã",
- "transcoding_acceleration_nvenc": "NVENCīŧéčĻ NVIDIA æžåĄīŧ",
- "transcoding_acceleration_qsv": "Quick SyncīŧéčĻ Intel 7äģŖåäģĨä¸į CPUīŧ",
- "transcoding_acceleration_rkmpp": "RKMPPīŧäģ
éį¨äē Rockchip SOCsīŧ",
+ "transcoding_acceleration_nvenc": "NVENCīŧéčĻNVIDIAæžåĄīŧ",
+ "transcoding_acceleration_qsv": "Quick SyncīŧéčĻIntel 7äģŖåäģĨä¸įCPUīŧ",
+ "transcoding_acceleration_rkmpp": "RKMPPīŧäģ
éį¨äēRockchip SOCsīŧ",
"transcoding_acceleration_vaapi": "č§éĸå é API",
"transcoding_accepted_audio_codecs": "æ¯æįéŗéĸįŧį æ ŧåŧ",
"transcoding_accepted_audio_codecs_description": "éæŠæ éčŊŦį įéŗéĸįŧį æ ŧåŧãäģ
å¨įšåŽįčŊŦį įįĨä¸įæã",
@@ -370,11 +370,11 @@
"transcoding_accepted_video_codecs_description": "éæŠæ éčŊŦį įč§éĸįŧį æ ŧåŧãäģ
å¨įšåŽįčŊŦį įįĨä¸įæã",
"transcoding_advanced_options_description": "大夿°į¨æˇä¸éčĻæ´æšįé饚",
"transcoding_audio_codec": "éŗéĸįŧį æ ŧåŧ",
- "transcoding_audio_codec_description": "Opus æ¯éŗč´¨æéĢįé饚īŧäŊå¨čæ§čŽžå¤æčŊ¯äģļä¸įå
ŧ厚æ§čžåˇŽã",
+ "transcoding_audio_codec_description": "Opusæ¯éŗč´¨æéĢįé饚īŧäŊå¨čæ§čŽžå¤æčŊ¯äģļä¸įå
ŧ厚æ§čžåˇŽã",
"transcoding_bitrate_description": "č§éĸį įéĢäēæå¤§éåļīŧææ ŧåŧä¸å¨æĨåå襨ä¸",
"transcoding_codecs_learn_more": "čĨčĻäēč§Ŗæ¤å¤äŊŋį¨įæ¯č¯č¯Ļæ
īŧ蝎æĨé
FFmpeg ææĄŖä¸į