diff --git a/README.md b/README.md
index 3cbb545fa4..ca8b1e3970 100644
--- a/README.md
+++ b/README.md
@@ -128,3 +128,9 @@ If you feel like this is the right cause and the app is something you are seeing
+
+## Star History
+
+
+
+
diff --git a/cli/package-lock.json b/cli/package-lock.json
index c3c11f3307..22d8585704 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -1325,9 +1325,9 @@
}
},
"node_modules/@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -5240,9 +5240,9 @@
}
},
"node_modules/vite": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
- "integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
+ "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
@@ -6481,9 +6481,9 @@
}
},
"@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
@@ -9364,9 +9364,9 @@
}
},
"vite": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
- "integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
+ "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true,
"requires": {
"esbuild": "^0.19.3",
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index 48a526c4c1..352309671b 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -17,7 +17,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
- command: [ "./start-server.sh" ]
+ command: [ "start.sh", "immich" ]
<<: *server-common
ports:
- 2283:3001
@@ -27,7 +27,7 @@ services:
immich-microservices:
container_name: immich_microservices
- command: [ "./start-microservices.sh" ]
+ command: [ "start.sh", "microservices" ]
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md
index 66f4d7b9f2..24919347f0 100644
--- a/docs/docs/administration/reverse-proxy.md
+++ b/docs/docs/administration/reverse-proxy.md
@@ -44,22 +44,13 @@ Below is an example config for Apache2 site configuration.
```
- ServerName
+ ServerName
+ ProxyRequests Off
+ ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
+ ProxyPassReverse / http://127.0.0.1:2283/
+ ProxyPreserveHost On
- ProxyRequests off
- ProxyVia on
-
- RewriteEngine On
- RewriteCond %{REQUEST_URI} ^/api/socket.io [NC]
- RewriteCond %{QUERY_STRING} transport=websocket [NC]
- RewriteRule /(.*) ws://localhost:2283/$1 [P,L]
-
- ProxyPass /api/socket.io ws://localhost:2283/api/socket.io
- ProxyPassReverse /api/socket.io ws://localhost:2283/api/socket.io
-
-
- ProxyPass http://localhost:2283/
- ProxyPassReverse http://localhost:2283/
-
```
+
+**timeout:** is measured in seconds, and it is particularly useful when long operations are triggered (i.e. Repair), so the server doesn't return an error.
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index d8804445d2..fb02aff2ff 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -4,6 +4,7 @@ name: immich-e2e
x-server-build: &server-common
image: immich-server:latest
+ container_name: immich-e2e-server
build:
context: ../
dockerfile: server/Dockerfile
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 5e0ffb0e2b..44155ac834 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -23,9 +23,13 @@
}
},
"../cli": {
+ "name": "@immich/cli",
"version": "2.0.8",
"dev": true,
"license": "GNU Affero General Public License version 3",
+ "dependencies": {
+ "lodash-es": "^4.17.21"
+ },
"bin": {
"immich": "dist/index.js"
},
@@ -34,6 +38,7 @@
"@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
+ "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -801,9 +806,9 @@
"dev": true
},
"node_modules/@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -899,9 +904,9 @@
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.0.tgz",
- "integrity": "sha512-e5Y5uK5NNoQMQaNitGQQjo9FoA5ZNcu7Bn6pH+dxUf48u6po1cX38kFBYUHZ9GNVkF4JLbncE0WeWwTw+nLrxg==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz",
+ "integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@@ -922,17 +927,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "1.3.0"
+ "vitest": "1.3.1"
}
},
"node_modules/@vitest/expect": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz",
- "integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
+ "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
"dev": true,
"dependencies": {
- "@vitest/spy": "1.3.0",
- "@vitest/utils": "1.3.0",
+ "@vitest/spy": "1.3.1",
+ "@vitest/utils": "1.3.1",
"chai": "^4.3.10"
},
"funding": {
@@ -940,12 +945,12 @@
}
},
"node_modules/@vitest/runner": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz",
- "integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
+ "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
"dev": true,
"dependencies": {
- "@vitest/utils": "1.3.0",
+ "@vitest/utils": "1.3.1",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
@@ -954,9 +959,9 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz",
- "integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
+ "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
"dev": true,
"dependencies": {
"magic-string": "^0.30.5",
@@ -968,9 +973,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz",
- "integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
+ "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
"dev": true,
"dependencies": {
"tinyspy": "^2.2.0"
@@ -980,9 +985,9 @@
}
},
"node_modules/@vitest/utils": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz",
- "integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
+ "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
"dev": true,
"dependencies": {
"diff-sequences": "^29.6.3",
@@ -2546,9 +2551,9 @@
}
},
"node_modules/vite": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
- "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
+ "integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
@@ -2601,9 +2606,9 @@
}
},
"node_modules/vite-node": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz",
- "integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
+ "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
@@ -2637,16 +2642,16 @@
}
},
"node_modules/vitest": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz",
- "integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
+ "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
"dev": true,
"dependencies": {
- "@vitest/expect": "1.3.0",
- "@vitest/runner": "1.3.0",
- "@vitest/snapshot": "1.3.0",
- "@vitest/spy": "1.3.0",
- "@vitest/utils": "1.3.0",
+ "@vitest/expect": "1.3.1",
+ "@vitest/runner": "1.3.1",
+ "@vitest/snapshot": "1.3.1",
+ "@vitest/spy": "1.3.1",
+ "@vitest/utils": "1.3.1",
"acorn-walk": "^8.3.2",
"chai": "^4.3.10",
"debug": "^4.3.4",
@@ -2660,7 +2665,7 @@
"tinybench": "^2.5.1",
"tinypool": "^0.8.2",
"vite": "^5.0.0",
- "vite-node": "1.3.0",
+ "vite-node": "1.3.1",
"why-is-node-running": "^2.2.2"
},
"bin": {
@@ -2675,8 +2680,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
- "@vitest/browser": "1.3.0",
- "@vitest/ui": "1.3.0",
+ "@vitest/browser": "1.3.1",
+ "@vitest/ui": "1.3.1",
"happy-dom": "*",
"jsdom": "*"
},
diff --git a/server/e2e/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts
similarity index 57%
rename from server/e2e/api/specs/activity.e2e-spec.ts
rename to e2e/src/api/specs/activity.e2e-spec.ts
index 47d2d7a199..738411338f 100644
--- a/server/e2e/api/specs/activity.e2e-spec.ts
+++ b/e2e/src/api/specs/activity.e2e-spec.ts
@@ -1,79 +1,94 @@
-import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain';
-import { ActivityController } from '@app/immich';
-import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
-import { ActivityEntity } from '@app/infra/entities';
-import { errorStub, userDto, uuidStub } from '@test/fixtures';
+import {
+ ActivityCreateDto,
+ AlbumResponseDto,
+ AssetResponseDto,
+ LoginResponseDto,
+ ReactionType,
+ createActivity as create,
+ createAlbum,
+} from '@immich/sdk';
+import { createUserDto, uuidDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
-import { api } from '../../client';
-import { testApp } from '../utils';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
-describe(`${ActivityController.name} (e2e)`, () => {
- let server: any;
+describe('/activity', () => {
let admin: LoginResponseDto;
- let asset: AssetFileUploadResponseDto;
- let album: AlbumResponseDto;
let nonOwner: LoginResponseDto;
+ let asset: AssetResponseDto;
+ let album: AlbumResponseDto;
+
+ const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
+ create(
+ { activityCreateDto: dto },
+ { headers: asBearerAuth(accessToken || admin.accessToken) }
+ );
beforeAll(async () => {
- server = (await testApp.create()).getHttpServer();
- await testApp.reset();
- await api.authApi.adminSignUp(server);
- admin = await api.authApi.adminLogin(server);
- asset = await api.assetApi.upload(server, admin.accessToken, 'example');
+ apiUtils.setup();
+ await dbUtils.reset();
- await api.userApi.create(server, admin.accessToken, userDto.user1);
- nonOwner = await api.authApi.login(server, userDto.user1);
-
- album = await api.albumApi.create(server, admin.accessToken, {
- albumName: 'Album 1',
- assetIds: [asset.id],
- sharedWithUserIds: [nonOwner.userId],
- });
- });
-
- afterAll(async () => {
- await testApp.teardown();
+ admin = await apiUtils.adminSetup();
+ nonOwner = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
+ asset = await apiUtils.createAsset(admin.accessToken);
+ album = await createAlbum(
+ {
+ createAlbumDto: {
+ albumName: 'Album 1',
+ assetIds: [asset.id],
+ sharedWithUserIds: [nonOwner.userId],
+ },
+ },
+ { headers: asBearerAuth(admin.accessToken) }
+ );
});
beforeEach(async () => {
- await testApp.reset({ entities: [ActivityEntity] });
+ await dbUtils.reset(['activity']);
});
describe('GET /activity', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).get('/activity');
+ const { status, body } = await request(app).get('/activity');
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should require an albumId', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
- expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
+ expect(body).toEqual(
+ errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
+ );
});
it('should reject an invalid albumId', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
- .query({ albumId: uuidStub.invalid })
+ .query({ albumId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
- expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
+ expect(body).toEqual(
+ errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
+ );
});
it('should reject an invalid assetId', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
- .query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid })
+ .query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
- expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
+ expect(body).toEqual(
+ errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID']))
+ );
});
it('should start off empty', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -82,22 +97,22 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should filter by album id', async () => {
- const album2 = await api.albumApi.create(server, admin.accessToken, {
- albumName: 'Album 2',
- assetIds: [asset.id],
- });
+ const album2 = await createAlbum(
+ {
+ createAlbumDto: {
+ albumName: 'Album 2',
+ assetIds: [asset.id],
+ },
+ },
+ { headers: asBearerAuth(admin.accessToken) }
+ );
+
const [reaction] = await Promise.all([
- api.activityApi.create(server, admin.accessToken, {
- albumId: album.id,
- type: ReactionType.LIKE,
- }),
- api.activityApi.create(server, admin.accessToken, {
- albumId: album2.id,
- type: ReactionType.LIKE,
- }),
+ createActivity({ albumId: album.id, type: ReactionType.Like }),
+ createActivity({ albumId: album2.id, type: ReactionType.Like }),
]);
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -108,15 +123,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by type=comment', async () => {
const [reaction] = await Promise.all([
- api.activityApi.create(server, admin.accessToken, {
+ createActivity({
albumId: album.id,
- type: ReactionType.COMMENT,
+ type: ReactionType.Comment,
comment: 'comment',
}),
- api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
+ createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
.query({ albumId: album.id, type: 'comment' })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -127,15 +142,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by type=like', async () => {
const [reaction] = await Promise.all([
- api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
- api.activityApi.create(server, admin.accessToken, {
+ createActivity({ albumId: album.id, type: ReactionType.Like }),
+ createActivity({
albumId: album.id,
- type: ReactionType.COMMENT,
+ type: ReactionType.Comment,
comment: 'comment',
}),
]);
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
.query({ albumId: album.id, type: 'like' })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -146,18 +161,18 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by userId', async () => {
const [reaction] = await Promise.all([
- api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
+ createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
- const response1 = await request(server)
+ const response1 = await request(app)
.get('/activity')
- .query({ albumId: album.id, userId: uuidStub.notFound })
+ .query({ albumId: album.id, userId: uuidDto.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response1.status).toEqual(200);
expect(response1.body.length).toBe(0);
- const response2 = await request(server)
+ const response2 = await request(app)
.get('/activity')
.query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -169,15 +184,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by assetId', async () => {
const [reaction] = await Promise.all([
- api.activityApi.create(server, admin.accessToken, {
+ createActivity({
albumId: album.id,
assetId: asset.id,
- type: ReactionType.LIKE,
+ type: ReactionType.Like,
}),
- api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
+ createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/activity')
.query({ albumId: album.id, assetId: asset.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -189,34 +204,45 @@ describe(`${ActivityController.name} (e2e)`, () => {
describe('POST /activity', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).post('/activity');
+ const { status, body } = await request(app).post('/activity');
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should require an albumId', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: uuidStub.invalid });
+ .send({ albumId: uuidDto.invalid });
expect(status).toEqual(400);
- expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
+ expect(body).toEqual(
+ errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
+ );
});
it('should require a comment when type is comment', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: uuidStub.notFound, type: 'comment', comment: null });
+ .send({ albumId: uuidDto.notFound, type: 'comment', comment: null });
expect(status).toEqual(400);
- expect(body).toEqual(errorStub.badRequest(['comment must be a string', 'comment should not be empty']));
+ expect(body).toEqual(
+ errorDto.badRequest([
+ 'comment must be a string',
+ 'comment should not be empty',
+ ])
+ );
});
it('should add a comment to an album', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: album.id, type: 'comment', comment: 'This is my first comment' });
+ .send({
+ albumId: album.id,
+ type: 'comment',
+ comment: 'This is my first comment',
+ });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
@@ -229,7 +255,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should add a like to an album', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
@@ -245,11 +271,11 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should return a 200 for a duplicate like on the album', async () => {
- const reaction = await api.activityApi.create(server, admin.accessToken, {
- albumId: album.id,
- type: ReactionType.LIKE,
- });
- const { status, body } = await request(server)
+ const [reaction] = await Promise.all([
+ createActivity({ albumId: album.id, type: ReactionType.Like }),
+ ]);
+
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
@@ -258,12 +284,14 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should not confuse an album like with an asset like', async () => {
- const reaction = await api.activityApi.create(server, admin.accessToken, {
- albumId: album.id,
- assetId: asset.id,
- type: ReactionType.LIKE,
- });
- const { status, body } = await request(server)
+ const [reaction] = await Promise.all([
+ createActivity({
+ albumId: album.id,
+ assetId: asset.id,
+ type: ReactionType.Like,
+ }),
+ ]);
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
@@ -272,10 +300,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should add a comment to an asset', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
- .send({ albumId: album.id, assetId: asset.id, type: 'comment', comment: 'This is my first comment' });
+ .send({
+ albumId: album.id,
+ assetId: asset.id,
+ type: 'comment',
+ comment: 'This is my first comment',
+ });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
@@ -288,7 +321,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should add a like to an asset', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
@@ -304,12 +337,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should return a 200 for a duplicate like on an asset', async () => {
- const reaction = await api.activityApi.create(server, admin.accessToken, {
- albumId: album.id,
- assetId: asset.id,
- type: ReactionType.LIKE,
- });
- const { status, body } = await request(server)
+ const [reaction] = await Promise.all([
+ createActivity({
+ albumId: album.id,
+ assetId: asset.id,
+ type: ReactionType.Like,
+ }),
+ ]);
+
+ const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
@@ -320,50 +356,52 @@ describe(`${ActivityController.name} (e2e)`, () => {
describe('DELETE /activity/:id', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).delete(`/activity/${uuidStub.notFound}`);
+ const { status, body } = await request(app).delete(
+ `/activity/${uuidDto.notFound}`
+ );
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid uuid', async () => {
- const { status, body } = await request(server)
- .delete(`/activity/${uuidStub.invalid}`)
+ const { status, body } = await request(app)
+ .delete(`/activity/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
+ expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should remove a comment from an album', async () => {
- const reaction = await api.activityApi.create(server, admin.accessToken, {
+ const reaction = await createActivity({
albumId: album.id,
- type: ReactionType.COMMENT,
+ type: ReactionType.Comment,
comment: 'This is a test comment',
});
- const { status } = await request(server)
+ const { status } = await request(app)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should remove a like from an album', async () => {
- const reaction = await api.activityApi.create(server, admin.accessToken, {
+ const reaction = await createActivity({
albumId: album.id,
- type: ReactionType.LIKE,
+ type: ReactionType.Like,
});
- const { status } = await request(server)
+ const { status } = await request(app)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should let the owner remove a comment by another user', async () => {
- const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
+ const reaction = await createActivity({
albumId: album.id,
- type: ReactionType.COMMENT,
+ type: ReactionType.Comment,
comment: 'This is a test comment',
});
- const { status } = await request(server)
+ const { status } = await request(app)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -371,28 +409,33 @@ describe(`${ActivityController.name} (e2e)`, () => {
});
it('should not let a user remove a comment by another user', async () => {
- const reaction = await api.activityApi.create(server, admin.accessToken, {
+ const reaction = await createActivity({
albumId: album.id,
- type: ReactionType.COMMENT,
+ type: ReactionType.Comment,
comment: 'This is a test comment',
});
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest('Not found or no activity.delete access'));
+ expect(body).toEqual(
+ errorDto.badRequest('Not found or no activity.delete access')
+ );
});
it('should let a non-owner remove their own comment', async () => {
- const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
- albumId: album.id,
- type: ReactionType.COMMENT,
- comment: 'This is a test comment',
- });
+ const reaction = await createActivity(
+ {
+ albumId: album.id,
+ type: ReactionType.Comment,
+ comment: 'This is a test comment',
+ },
+ nonOwner.accessToken
+ );
- const { status } = await request(server)
+ const { status } = await request(app)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
diff --git a/server/e2e/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts
similarity index 52%
rename from server/e2e/api/specs/album.e2e-spec.ts
rename to e2e/src/api/specs/album.e2e-spec.ts
index 312816035c..c131edc49c 100644
--- a/server/e2e/api/specs/album.e2e-spec.ts
+++ b/e2e/src/api/specs/album.e2e-spec.ts
@@ -1,11 +1,15 @@
-import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
-import { AlbumController } from '@app/immich';
-import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
-import { SharedLinkType } from '@app/infra/entities';
-import { errorStub, userDto, uuidStub } from '@test/fixtures';
+import {
+ AlbumResponseDto,
+ AssetResponseDto,
+ LoginResponseDto,
+ SharedLinkType,
+ deleteUser,
+} from '@immich/sdk';
+import { createUserDto, uuidDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
-import { api } from '../../client';
-import { testApp } from '../utils';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const user1SharedUser = 'user1SharedUser';
const user1SharedLink = 'user1SharedLink';
@@ -14,193 +18,327 @@ const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
-describe(`${AlbumController.name} (e2e)`, () => {
- let server: any;
+describe('/album', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
- let user1Asset: AssetFileUploadResponseDto;
+ let user1Asset1: AssetResponseDto;
+ let user1Asset2: AssetResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
+ let user3: LoginResponseDto; // deleted
beforeAll(async () => {
- server = (await testApp.create()).getHttpServer();
- });
+ apiUtils.setup();
+ await dbUtils.reset();
- afterAll(async () => {
- await testApp.teardown();
- });
+ admin = await apiUtils.adminSetup();
- beforeEach(async () => {
- await testApp.reset();
- await api.authApi.adminSignUp(server);
- admin = await api.authApi.adminLogin(server);
-
- await Promise.all([
- api.userApi.create(server, admin.accessToken, userDto.user1),
- api.userApi.create(server, admin.accessToken, userDto.user2),
+ [user1, user2, user3] = await Promise.all([
+ apiUtils.userSetup(admin.accessToken, createUserDto.user1),
+ apiUtils.userSetup(admin.accessToken, createUserDto.user2),
+ apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
- [user1, user2] = await Promise.all([
- api.authApi.login(server, userDto.user1),
- api.authApi.login(server, userDto.user2),
+ [user1Asset1, user1Asset2] = await Promise.all([
+ apiUtils.createAsset(user1.accessToken),
+ apiUtils.createAsset(user1.accessToken),
]);
- user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
-
const albums = await Promise.all([
// user 1
- api.albumApi.create(server, user1.accessToken, {
+ apiUtils.createAlbum(user1.accessToken, {
albumName: user1SharedUser,
sharedWithUserIds: [user2.userId],
- assetIds: [user1Asset.id],
+ assetIds: [user1Asset1.id],
+ }),
+ apiUtils.createAlbum(user1.accessToken, {
+ albumName: user1SharedLink,
+ assetIds: [user1Asset1.id],
+ }),
+ apiUtils.createAlbum(user1.accessToken, {
+ albumName: user1NotShared,
+ assetIds: [user1Asset1.id, user1Asset2.id],
}),
- api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
- api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
// user 2
- api.albumApi.create(server, user2.accessToken, {
+ apiUtils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
sharedWithUserIds: [user1.userId],
- assetIds: [user1Asset.id],
+ assetIds: [user1Asset1.id],
+ }),
+ apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
+ apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
+
+ // user 3
+ apiUtils.createAlbum(user3.accessToken, {
+ albumName: 'Deleted',
+ sharedWithUserIds: [user1.userId],
}),
- api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }),
- api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
]);
user1Albums = albums.slice(0, 3);
- user2Albums = albums.slice(3);
+ user2Albums = albums.slice(3, 6);
await Promise.all([
// add shared link to user1SharedLink album
- api.sharedLinkApi.create(server, user1.accessToken, {
- type: SharedLinkType.ALBUM,
+ apiUtils.createSharedLink(user1.accessToken, {
+ type: SharedLinkType.Album,
albumId: user1Albums[1].id,
}),
-
// add shared link to user2SharedLink album
- api.sharedLinkApi.create(server, user2.accessToken, {
- type: SharedLinkType.ALBUM,
+ apiUtils.createSharedLink(user2.accessToken, {
+ type: SharedLinkType.Album,
albumId: user2Albums[1].id,
}),
]);
+
+ await deleteUser(
+ { id: user3.userId },
+ { headers: asBearerAuth(admin.accessToken) }
+ );
});
describe('GET /album', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).get('/album');
+ const { status, body } = await request(app).get('/album');
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should reject an invalid shared param', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
- expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value']));
+ expect(body).toEqual(
+ errorDto.badRequest(['shared must be a boolean value'])
+ );
});
it('should reject an invalid assetId param', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
- expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID']));
+ expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
});
it('should not return shared albums with a deleted owner', async () => {
- await api.userApi.delete(server, admin.accessToken, user1.userId);
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/album?shared=true')
- .set('Authorization', `Bearer ${user2.accessToken}`);
+ .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
- expect(body).toHaveLength(1);
+ expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
- expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }),
- ]),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1SharedLink,
+ shared: true,
+ }),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1SharedUser,
+ shared: true,
+ }),
+ expect.objectContaining({
+ ownerId: user2.userId,
+ albumName: user2SharedUser,
+ shared: true,
+ }),
+ ])
);
});
it('should return the album collection including owned and shared', async () => {
- const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
+ const { status, body } = await request(app)
+ .get('/album')
+ .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
- expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
- expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
- expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
- ]),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1SharedUser,
+ shared: true,
+ }),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1SharedLink,
+ shared: true,
+ }),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1NotShared,
+ shared: false,
+ }),
+ ])
);
});
it('should return the album collection filtered by shared', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
- expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
- expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
- expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }),
- ]),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1SharedUser,
+ shared: true,
+ }),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1SharedLink,
+ shared: true,
+ }),
+ expect.objectContaining({
+ ownerId: user2.userId,
+ albumName: user2SharedUser,
+ shared: true,
+ }),
+ ])
);
});
it('should return the album collection filtered by NOT shared', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
- expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
- ]),
+ expect.objectContaining({
+ ownerId: user1.userId,
+ albumName: user1NotShared,
+ shared: false,
+ }),
+ ])
);
});
it('should return the album collection filtered by assetId', async () => {
- const asset = await api.assetApi.upload(server, user1.accessToken, 'example2');
- await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] });
- const { status, body } = await request(server)
- .get(`/album?assetId=${asset.id}`)
+ const { status, body } = await request(app)
+ .get(`/album?assetId=${user1Asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
it('should return the album collection filtered by assetId and ignores shared=true', async () => {
- const { status, body } = await request(server)
- .get(`/album?shared=true&assetId=${user1Asset.id}`)
+ const { status, body } = await request(app)
+ .get(`/album?shared=true&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
it('should return the album collection filtered by assetId and ignores shared=false', async () => {
- const { status, body } = await request(server)
- .get(`/album?shared=false&assetId=${user1Asset.id}`)
+ const { status, body } = await request(app)
+ .get(`/album?shared=false&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
});
+ describe('GET /album/:id', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app).get(
+ `/album/${user1Albums[0].id}`
+ );
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should return album info for own album', async () => {
+ const { status, body } = await request(app)
+ .get(`/album/${user1Albums[0].id}?withoutAssets=false`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ ...user1Albums[0],
+ assets: [expect.objectContaining(user1Albums[0].assets[0])],
+ });
+ });
+
+ it('should return album info for shared album', async () => {
+ const { status, body } = await request(app)
+ .get(`/album/${user2Albums[0].id}?withoutAssets=false`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ ...user2Albums[0],
+ assets: [expect.objectContaining(user2Albums[0].assets[0])],
+ });
+ });
+
+ it('should return album info with assets when withoutAssets is undefined', async () => {
+ const { status, body } = await request(app)
+ .get(`/album/${user1Albums[0].id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ ...user1Albums[0],
+ assets: [expect.objectContaining(user1Albums[0].assets[0])],
+ });
+ });
+
+ it('should return album info without assets when withoutAssets is true', async () => {
+ const { status, body } = await request(app)
+ .get(`/album/${user1Albums[0].id}?withoutAssets=true`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ ...user1Albums[0],
+ assets: [],
+ assetCount: 1,
+ });
+ });
+ });
+
+ describe('GET /album/count', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app).get('/album/count');
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should return total count of albums the user has access to', async () => {
+ const { status, body } = await request(app)
+ .get('/album/count')
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
+ });
+ });
+
describe('POST /album', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).post('/album').send({ albumName: 'New album' });
+ const { status, body } = await request(app)
+ .post('/album')
+ .send({ albumName: 'New album' });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should create an album', async () => {
- const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
+ const { status, body } = await request(app)
+ .post('/album')
+ .send({ albumName: 'New album' })
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+ expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
createdAt: expect.any(String),
@@ -220,113 +358,56 @@ describe(`${AlbumController.name} (e2e)`, () => {
});
});
- describe('GET /album/count', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(server).get('/album/count');
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- it('should return total count of albums the user has access to', async () => {
- const { status, body } = await request(server)
- .get('/album/count')
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
- });
- });
-
- describe('GET /album/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`);
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- it('should return album info for own album', async () => {
- const { status, body } = await request(server)
- .get(`/album/${user1Albums[0].id}?withoutAssets=false`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
- });
-
- it('should return album info for shared album', async () => {
- const { status, body } = await request(server)
- .get(`/album/${user2Albums[0].id}?withoutAssets=false`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] });
- });
-
- it('should return album info with assets when withoutAssets is undefined', async () => {
- const { status, body } = await request(server)
- .get(`/album/${user1Albums[0].id}`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
- });
-
- it('should return album info without assets when withoutAssets is true', async () => {
- const { status, body } = await request(server)
- .get(`/album/${user1Albums[0].id}?withoutAssets=true`)
- .set('Authorization', `Bearer ${user1.accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- ...user1Albums[0],
- assets: [],
- assetCount: 1,
- });
- });
- });
-
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`);
+ const { status, body } = await request(app).put(
+ `/album/${user1Albums[0].id}/assets`
+ );
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should be able to add own asset to own album', async () => {
- const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
- const { status, body } = await request(server)
+ const asset = await apiUtils.createAsset(user1.accessToken);
+ const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
+ expect(body).toEqual([
+ expect.objectContaining({ id: asset.id, success: true }),
+ ]);
});
it('should be able to add own asset to shared album', async () => {
- const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
- const { status, body } = await request(server)
+ const asset = await apiUtils.createAsset(user1.accessToken);
+ const { status, body } = await request(app)
.put(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
+ expect(body).toEqual([
+ expect.objectContaining({ id: asset.id, success: true }),
+ ]);
});
});
describe('PATCH /album/:id', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server)
- .patch(`/album/${uuidStub.notFound}`)
+ const { status, body } = await request(app)
+ .patch(`/album/${uuidDto.notFound}`)
.send({ albumName: 'New album name' });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should update an album', async () => {
- const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
- const { status, body } = await request(server)
+ const album = await apiUtils.createAlbum(user1.accessToken, {
+ albumName: 'New album',
+ });
+ const { status, body } = await request(app)
.patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
@@ -345,52 +426,68 @@ describe(`${AlbumController.name} (e2e)`, () => {
describe('DELETE /album/:id/assets', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`)
- .send({ ids: [user1Asset.id] });
+ .send({ ids: [user1Asset1.id] });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- it('should be able to remove own asset from own album', async () => {
- const { status, body } = await request(server)
- .delete(`/album/${user1Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [user1Asset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
- });
-
- it('should be able to remove own asset from shared album', async () => {
- const { status, body } = await request(server)
- .delete(`/album/${user2Albums[0].id}/assets`)
- .set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ ids: [user1Asset.id] });
-
- expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should not be able to remove foreign asset from own album', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ ids: [user1Asset.id] });
+ .send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
+ expect(body).toEqual([
+ expect.objectContaining({
+ id: user1Asset1.id,
+ success: false,
+ error: 'no_permission',
+ }),
+ ]);
});
it('should not be able to remove foreign asset from foreign album', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
- .send({ ids: [user1Asset.id] });
+ .send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
- expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
+ expect(body).toEqual([
+ expect.objectContaining({
+ id: user1Asset1.id,
+ success: false,
+ error: 'no_permission',
+ }),
+ ]);
+ });
+
+ it('should be able to remove own asset from own album', async () => {
+ const { status, body } = await request(app)
+ .delete(`/album/${user1Albums[0].id}/assets`)
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [user1Asset1.id] });
+
+ expect(status).toBe(200);
+ expect(body).toEqual([
+ expect.objectContaining({ id: user1Asset1.id, success: true }),
+ ]);
+ });
+
+ it('should be able to remove own asset from shared album', async () => {
+ const { status, body } = await request(app)
+ .delete(`/album/${user2Albums[0].id}/assets`)
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [user1Asset1.id] });
+
+ expect(status).toBe(200);
+ expect(body).toEqual([
+ expect.objectContaining({ id: user1Asset1.id, success: true }),
+ ]);
});
});
@@ -398,51 +495,57 @@ describe(`${AlbumController.name} (e2e)`, () => {
let album: AlbumResponseDto;
beforeEach(async () => {
- album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' });
+ album = await apiUtils.createAlbum(user1.accessToken, {
+ albumName: 'testAlbum',
+ });
});
it('should require authentication', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should be able to add user to own album', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] }));
+ expect(body).toEqual(
+ expect.objectContaining({
+ sharedUsers: [expect.objectContaining({ id: user2.userId })],
+ })
+ );
});
it('should not be able to share album with owner', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user1.userId] });
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner'));
+ expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
});
it('should not be able to add existing user to shared album', async () => {
- await request(server)
+ await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest('User already added'));
+ expect(body).toEqual(errorDto.badRequest('User already added'));
});
});
});
diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts
new file mode 100644
index 0000000000..073106e728
--- /dev/null
+++ b/e2e/src/api/specs/audit.e2e-spec.ts
@@ -0,0 +1,51 @@
+import {
+ deleteAssets,
+ getAuditFiles,
+ updateAsset,
+ type LoginResponseDto,
+} from '@immich/sdk';
+import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+describe('/audit', () => {
+ let admin: LoginResponseDto;
+
+ beforeAll(async () => {
+ apiUtils.setup();
+ await dbUtils.reset();
+ await fileUtils.reset();
+
+ admin = await apiUtils.adminSetup();
+ });
+
+ describe('GET :/file-report', () => {
+ it('excludes assets without issues from report', async () => {
+ const [trashedAsset, archivedAsset, _] = await Promise.all([
+ apiUtils.createAsset(admin.accessToken),
+ apiUtils.createAsset(admin.accessToken),
+ apiUtils.createAsset(admin.accessToken),
+ ]);
+
+ await Promise.all([
+ deleteAssets(
+ { assetBulkDeleteDto: { ids: [trashedAsset.id] } },
+ { headers: asBearerAuth(admin.accessToken) }
+ ),
+ updateAsset(
+ {
+ id: archivedAsset.id,
+ updateAssetDto: { isArchived: true },
+ },
+ { headers: asBearerAuth(admin.accessToken) }
+ ),
+ ]);
+
+ const body = await getAuditFiles({
+ headers: asBearerAuth(admin.accessToken),
+ });
+
+ expect(body.orphans).toHaveLength(0);
+ expect(body.extras).toHaveLength(0);
+ });
+ });
+});
diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts
new file mode 100644
index 0000000000..3f17eac220
--- /dev/null
+++ b/e2e/src/api/specs/person.e2e-spec.ts
@@ -0,0 +1,178 @@
+import { LoginResponseDto, PersonResponseDto } from '@immich/sdk';
+import { uuidDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { apiUtils, app, dbUtils } from 'src/utils';
+import request from 'supertest';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+describe('/activity', () => {
+ let admin: LoginResponseDto;
+ let visiblePerson: PersonResponseDto;
+ let hiddenPerson: PersonResponseDto;
+
+ beforeAll(async () => {
+ apiUtils.setup();
+ await dbUtils.reset();
+ admin = await apiUtils.adminSetup();
+ });
+
+ beforeEach(async () => {
+ await dbUtils.reset(['person']);
+
+ [visiblePerson, hiddenPerson] = await Promise.all([
+ apiUtils.createPerson(admin.accessToken, {
+ name: 'visible_person',
+ }),
+ apiUtils.createPerson(admin.accessToken, {
+ name: 'hidden_person',
+ isHidden: true,
+ }),
+ ]);
+
+ const asset = await apiUtils.createAsset(admin.accessToken);
+
+ await Promise.all([
+ dbUtils.createFace({ assetId: asset.id, personId: visiblePerson.id }),
+ dbUtils.createFace({ assetId: asset.id, personId: hiddenPerson.id }),
+ ]);
+ });
+
+ describe('GET /person', () => {
+ beforeEach(async () => {});
+
+ it('should require authentication', async () => {
+ const { status, body } = await request(app).get('/person');
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should return all people (including hidden)', async () => {
+ const { status, body } = await request(app)
+ .get('/person')
+ .set('Authorization', `Bearer ${admin.accessToken}`)
+ .query({ withHidden: true });
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ total: 2,
+ hidden: 1,
+ people: [
+ expect.objectContaining({ name: 'visible_person' }),
+ expect.objectContaining({ name: 'hidden_person' }),
+ ],
+ });
+ });
+
+ it('should return only visible people', async () => {
+ const { status, body } = await request(app)
+ .get('/person')
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ total: 2,
+ hidden: 1,
+ people: [expect.objectContaining({ name: 'visible_person' })],
+ });
+ });
+ });
+
+ describe('GET /person/:id', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app).get(
+ `/person/${uuidDto.notFound}`
+ );
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should throw error if person with id does not exist', async () => {
+ const { status, body } = await request(app)
+ .get(`/person/${uuidDto.notFound}`)
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest());
+ });
+
+ it('should return person information', async () => {
+ const { status, body } = await request(app)
+ .get(`/person/${visiblePerson.id}`)
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
+ });
+ });
+
+ describe('PUT /person/:id', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app).put(
+ `/person/${uuidDto.notFound}`
+ );
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ for (const { key, type } of [
+ { key: 'name', type: 'string' },
+ { key: 'featureFaceAssetId', type: 'string' },
+ { key: 'isHidden', type: 'boolean value' },
+ ]) {
+ it(`should not allow null ${key}`, async () => {
+ const { status, body } = await request(app)
+ .put(`/person/${visiblePerson.id}`)
+ .set('Authorization', `Bearer ${admin.accessToken}`)
+ .send({ [key]: null });
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest([`${key} must be a ${type}`]));
+ });
+ }
+
+ it('should not accept invalid birth dates', async () => {
+ for (const { birthDate, response } of [
+ { birthDate: false, response: 'Not found or no person.write access' },
+ { birthDate: 'false', response: ['birthDate must be a Date instance'] },
+ {
+ birthDate: '123567',
+ response: 'Not found or no person.write access',
+ },
+ { birthDate: 123567, response: 'Not found or no person.write access' },
+ ]) {
+ const { status, body } = await request(app)
+ .put(`/person/${uuidDto.notFound}`)
+ .set('Authorization', `Bearer ${admin.accessToken}`)
+ .send({ birthDate });
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest(response));
+ }
+ });
+
+ it('should update a date of birth', async () => {
+ const { status, body } = await request(app)
+ .put(`/person/${visiblePerson.id}`)
+ .set('Authorization', `Bearer ${admin.accessToken}`)
+ .send({ birthDate: '1990-01-01T05:00:00.000Z' });
+ expect(status).toBe(200);
+ expect(body).toMatchObject({ birthDate: '1990-01-01' });
+ });
+
+ it('should clear a date of birth', async () => {
+ // TODO ironically this uses the update endpoint to create the person
+ const person = await apiUtils.createPerson(admin.accessToken, {
+ birthDate: new Date('1990-01-01').toISOString(),
+ });
+
+ expect(person.birthDate).toBeDefined();
+
+ const { status, body } = await request(app)
+ .put(`/person/${person.id}`)
+ .set('Authorization', `Bearer ${admin.accessToken}`)
+ .send({ birthDate: null });
+ expect(status).toBe(200);
+ expect(body).toMatchObject({ birthDate: null });
+ });
+ });
+});
diff --git a/server/e2e/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts
similarity index 53%
rename from server/e2e/api/specs/shared-link.e2e-spec.ts
rename to e2e/src/api/specs/shared-link.e2e-spec.ts
index 034b2f2637..e791c447ac 100644
--- a/server/e2e/api/specs/shared-link.e2e-spec.ts
+++ b/e2e/src/api/specs/shared-link.e2e-spec.ts
@@ -1,21 +1,21 @@
import {
AlbumResponseDto,
AssetResponseDto,
- IAssetRepository,
LoginResponseDto,
+ SharedLinkCreateDto,
SharedLinkResponseDto,
-} from '@app/domain';
-import { SharedLinkController } from '@app/immich';
-import { SharedLinkType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
-import { errorStub, userDto, uuidStub } from '@test/fixtures';
-import { DateTime } from 'luxon';
+ SharedLinkType,
+ createSharedLink as create,
+ createAlbum,
+ deleteUser,
+} from '@immich/sdk';
+import { createUserDto, uuidDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
-import { api } from '../../client';
-import { testApp } from '../utils';
+import { beforeAll, describe, expect, it } from 'vitest';
-describe(`${SharedLinkController.name} (e2e)`, () => {
- let server: any;
+describe('/shared-link', () => {
let admin: LoginResponseDto;
let asset1: AssetResponseDto;
let asset2: AssetResponseDto;
@@ -30,97 +30,96 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
let linkWithAssets: SharedLinkResponseDto;
let linkWithMetadata: SharedLinkResponseDto;
let linkWithoutMetadata: SharedLinkResponseDto;
- let app: INestApplication;
beforeAll(async () => {
- app = await testApp.create();
- server = app.getHttpServer();
- const assetRepository = app.get(IAssetRepository);
+ apiUtils.setup();
+ await dbUtils.reset();
- await testApp.reset();
- await api.authApi.adminSignUp(server);
- admin = await api.authApi.adminLogin(server);
-
- await Promise.all([
- api.userApi.create(server, admin.accessToken, userDto.user1),
- api.userApi.create(server, admin.accessToken, userDto.user2),
- ]);
+ admin = await apiUtils.adminSetup();
[user1, user2] = await Promise.all([
- api.authApi.login(server, userDto.user1),
- api.authApi.login(server, userDto.user2),
+ apiUtils.userSetup(admin.accessToken, createUserDto.user1),
+ apiUtils.userSetup(admin.accessToken, createUserDto.user2),
]);
[asset1, asset2] = await Promise.all([
- api.assetApi.create(server, user1.accessToken),
- api.assetApi.create(server, user1.accessToken),
+ apiUtils.createAsset(user1.accessToken),
+ apiUtils.createAsset(user1.accessToken),
]);
- await assetRepository.upsertExif({
- assetId: asset1.id,
- longitude: -108.400968333333,
- latitude: 39.115,
- orientation: '1',
- dateTimeOriginal: DateTime.fromISO('2022-01-10T19:15:44.310Z').toJSDate(),
- timeZone: 'UTC-4',
- state: 'Mesa County, Colorado',
- country: 'United States of America',
- });
-
[album, deletedAlbum, metadataAlbum] = await Promise.all([
- api.albumApi.create(server, user1.accessToken, { albumName: 'album' }),
- api.albumApi.create(server, user2.accessToken, { albumName: 'deleted album' }),
- api.albumApi.create(server, user1.accessToken, { albumName: 'metadata album', assetIds: [asset1.id] }),
+ createAlbum(
+ { createAlbumDto: { albumName: 'album' } },
+ { headers: asBearerAuth(user1.accessToken) }
+ ),
+ createAlbum(
+ { createAlbumDto: { albumName: 'deleted album' } },
+ { headers: asBearerAuth(user2.accessToken) }
+ ),
+ createAlbum(
+ {
+ createAlbumDto: {
+ albumName: 'metadata album',
+ assetIds: [asset1.id],
+ },
+ },
+ { headers: asBearerAuth(user1.accessToken) }
+ ),
]);
- [linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
- await Promise.all([
- api.sharedLinkApi.create(server, user2.accessToken, {
- type: SharedLinkType.ALBUM,
- albumId: deletedAlbum.id,
- }),
- api.sharedLinkApi.create(server, user1.accessToken, {
- type: SharedLinkType.ALBUM,
- albumId: album.id,
- }),
- api.sharedLinkApi.create(server, user1.accessToken, {
- type: SharedLinkType.INDIVIDUAL,
- assetIds: [asset1.id],
- }),
- api.sharedLinkApi.create(server, user1.accessToken, {
- type: SharedLinkType.ALBUM,
- albumId: album.id,
- password: 'foo',
- }),
- api.sharedLinkApi.create(server, user1.accessToken, {
- type: SharedLinkType.ALBUM,
- albumId: metadataAlbum.id,
- showMetadata: true,
- }),
- api.sharedLinkApi.create(server, user1.accessToken, {
- type: SharedLinkType.ALBUM,
- albumId: metadataAlbum.id,
- showMetadata: false,
- }),
- ]);
+ [
+ linkWithDeletedAlbum,
+ linkWithAlbum,
+ linkWithAssets,
+ linkWithPassword,
+ linkWithMetadata,
+ linkWithoutMetadata,
+ ] = await Promise.all([
+ apiUtils.createSharedLink(user2.accessToken, {
+ type: SharedLinkType.Album,
+ albumId: deletedAlbum.id,
+ }),
+ apiUtils.createSharedLink(user1.accessToken, {
+ type: SharedLinkType.Album,
+ albumId: album.id,
+ }),
+ apiUtils.createSharedLink(user1.accessToken, {
+ type: SharedLinkType.Individual,
+ assetIds: [asset1.id],
+ }),
+ apiUtils.createSharedLink(user1.accessToken, {
+ type: SharedLinkType.Album,
+ albumId: album.id,
+ password: 'foo',
+ }),
+ apiUtils.createSharedLink(user1.accessToken, {
+ type: SharedLinkType.Album,
+ albumId: metadataAlbum.id,
+ showMetadata: true,
+ }),
+ apiUtils.createSharedLink(user1.accessToken, {
+ type: SharedLinkType.Album,
+ albumId: metadataAlbum.id,
+ showMetadata: false,
+ }),
+ ]);
- await api.userApi.delete(server, admin.accessToken, user2.userId);
- });
-
- afterAll(async () => {
- await testApp.teardown();
+ await deleteUser(
+ { id: user2.userId },
+ { headers: asBearerAuth(admin.accessToken) }
+ );
});
describe('GET /shared-link', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).get('/shared-link');
+ const { status, body } = await request(app).get('/shared-link');
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should get all shared links created by user', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -133,12 +132,12 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
expect.objectContaining({ id: linkWithPassword.id }),
expect.objectContaining({ id: linkWithMetadata.id }),
expect.objectContaining({ id: linkWithoutMetadata.id }),
- ]),
+ ])
);
});
it('should not get shared links created by other users', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/shared-link')
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -149,7 +148,7 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => {
- const { status } = await request(server)
+ const { status } = await request(app)
.get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -157,52 +156,66 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
});
it('should get data for correct shared link', async () => {
- const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithAlbum.key });
+ const { status, body } = await request(app)
+ .get('/shared-link/me')
+ .query({ key: linkWithAlbum.key });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
album,
userId: user1.userId,
- type: SharedLinkType.ALBUM,
- }),
+ type: SharedLinkType.Album,
+ })
);
});
it('should return unauthorized for incorrect shared link', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithAlbum.key + 'foo' });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.invalidShareKey);
+ expect(body).toEqual(errorDto.invalidShareKey);
});
it('should return unauthorized if target has been soft deleted', async () => {
- const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key });
+ const { status, body } = await request(app)
+ .get('/shared-link/me')
+ .query({ key: linkWithDeletedAlbum.key });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.invalidShareKey);
+ expect(body).toEqual(errorDto.invalidShareKey);
});
it('should return unauthorized for password protected link', async () => {
- const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithPassword.key });
+ const { status, body } = await request(app)
+ .get('/shared-link/me')
+ .query({ key: linkWithPassword.key });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.invalidSharePassword);
+ expect(body).toEqual(errorDto.invalidSharePassword);
});
it('should get data for correct password protected link', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithPassword.key, password: 'foo' });
expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
+ expect(body).toEqual(
+ expect.objectContaining({
+ album,
+ userId: user1.userId,
+ type: SharedLinkType.Album,
+ })
+ );
});
it('should return metadata for album shared link', async () => {
- const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithMetadata.key });
+ const { status, body } = await request(app)
+ .get('/shared-link/me')
+ .query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
@@ -211,22 +224,16 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
originalFileName: 'example',
localDateTime: expect.any(String),
fileCreatedAt: expect.any(String),
- exifInfo: expect.objectContaining({
- longitude: -108.400968333333,
- latitude: 39.115,
- orientation: '1',
- dateTimeOriginal: expect.any(String),
- timeZone: 'UTC-4',
- state: 'Mesa County, Colorado',
- country: 'United States of America',
- }),
- }),
+ exifInfo: expect.any(Object),
+ })
);
expect(body.album).toBeDefined();
});
it('should not return metadata for album shared link without metadata', async () => {
- const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithoutMetadata.key });
+ const { status, body } = await request(app)
+ .get('/shared-link/me')
+ .query({ key: linkWithoutMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
@@ -242,127 +249,150 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('GET /shared-link/:id', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).get(`/shared-link/${linkWithAlbum.id}`);
+ const { status, body } = await request(app).get(
+ `/shared-link/${linkWithAlbum.id}`
+ );
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should get shared link by id', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
+ expect(body).toEqual(
+ expect.objectContaining({
+ album,
+ userId: user1.userId,
+ type: SharedLinkType.Album,
+ })
+ );
});
it('should not get shared link by id if user has not created the link or it does not exist', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
+ expect(body).toEqual(
+ expect.objectContaining({ message: 'Shared link not found' })
+ );
});
});
describe('POST /shared-link', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/shared-link')
- .send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound });
+ .send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should require a type and the correspondent asset/album id', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest());
+ expect(body).toEqual(errorDto.badRequest());
});
it('should require an asset/album id', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ type: SharedLinkType.ALBUM });
+ .send({ type: SharedLinkType.Album });
expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
+ expect(body).toEqual(
+ expect.objectContaining({ message: 'Invalid albumId' })
+ );
});
it('should require a valid asset id', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound });
+ .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
expect(status).toBe(400);
- expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
+ expect(body).toEqual(
+ expect.objectContaining({ message: 'Invalid assetIds' })
+ );
});
it('should create a shared link', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
- .send({ type: SharedLinkType.ALBUM, albumId: album.id });
+ .send({ type: SharedLinkType.Album, albumId: album.id });
expect(status).toBe(201);
- expect(body).toEqual(expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId }));
+ expect(body).toEqual(
+ expect.objectContaining({
+ type: SharedLinkType.Album,
+ userId: user1.userId,
+ })
+ );
});
});
describe('PATCH /shared-link/:id', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.patch(`/shared-link/${linkWithAlbum.id}`)
.send({ description: 'foo' });
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should fail if invalid link', async () => {
- const { status, body } = await request(server)
- .patch(`/shared-link/${uuidStub.notFound}`)
+ const { status, body } = await request(app)
+ .patch(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest());
+ expect(body).toEqual(errorDto.badRequest());
});
it('should update shared link', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.patch(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
expect(status).toBe(200);
expect(body).toEqual(
- expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId, description: 'foo' }),
+ expect.objectContaining({
+ type: SharedLinkType.Album,
+ userId: user1.userId,
+ description: 'foo',
+ })
);
});
});
describe('PUT /shared-link/:id/assets', () => {
it('should not add assets to shared link (album)', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.put(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
+ expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
});
it('should add an assets to a shared link (individual)', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.put(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
@@ -374,17 +404,17 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('DELETE /shared-link/:id/assets', () => {
it('should not remove assets from a shared link (album)', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.delete(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
+ expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
});
it('should remove assets from a shared link (individual)', async () => {
- const { status, body } = await request(server)
+ const { status, body } = await request(app)
.delete(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
@@ -396,23 +426,25 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => {
- const { status, body } = await request(server).delete(`/shared-link/${linkWithAlbum.id}`);
+ const { status, body } = await request(app).delete(
+ `/shared-link/${linkWithAlbum.id}`
+ );
expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
+ expect(body).toEqual(errorDto.unauthorized);
});
it('should fail if invalid link', async () => {
- const { status, body } = await request(server)
- .delete(`/shared-link/${uuidStub.notFound}`)
+ const { status, body } = await request(app)
+ .delete(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest());
+ expect(body).toEqual(errorDto.badRequest());
});
it('should delete a shared link', async () => {
- const { status } = await request(server)
+ const { status } = await request(app)
.delete(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts
index 74e1646802..9bfb47284a 100644
--- a/e2e/src/api/specs/user.e2e-spec.ts
+++ b/e2e/src/api/specs/user.e2e-spec.ts
@@ -1,26 +1,31 @@
-import {
- LoginResponseDto,
- UserResponseDto,
- createUser,
- deleteUser,
- getUserById,
-} from '@immich/sdk';
+import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk';
import { createUserDto, userDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
-import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { beforeAll, describe, expect, it } from 'vitest';
describe('/server-info', () => {
let admin: LoginResponseDto;
+ let deletedUser: LoginResponseDto;
+ let userToDelete: LoginResponseDto;
+ let nonAdmin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
- });
-
- beforeEach(async () => {
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
+
+ [deletedUser, nonAdmin, userToDelete] = await Promise.all([
+ apiUtils.userSetup(admin.accessToken, createUserDto.user1),
+ apiUtils.userSetup(admin.accessToken, createUserDto.user2),
+ apiUtils.userSetup(admin.accessToken, createUserDto.user3),
+ ]);
+
+ await deleteUser(
+ { id: deletedUser.userId },
+ { headers: asBearerAuth(admin.accessToken) }
+ );
});
describe('GET /user', () => {
@@ -30,60 +35,54 @@ describe('/server-info', () => {
expect(body).toEqual(errorDto.unauthorized);
});
- it('should start with the admin', async () => {
+ it('should get users', async () => {
const { status, body } = await request(app)
.get('/user')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
- expect(body).toHaveLength(1);
- expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
+ expect(body).toHaveLength(4);
+ expect(body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ email: 'admin@immich.cloud' }),
+ expect.objectContaining({ email: 'user1@immich.cloud' }),
+ expect.objectContaining({ email: 'user2@immich.cloud' }),
+ expect.objectContaining({ email: 'user3@immich.cloud' }),
+ ])
+ );
});
it('should hide deleted users', async () => {
- const user1 = await apiUtils.userSetup(
- admin.accessToken,
- createUserDto.user1
- );
- await deleteUser(
- { id: user1.userId },
- { headers: asBearerAuth(admin.accessToken) }
- );
-
const { status, body } = await request(app)
.get(`/user`)
.query({ isAll: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
+ expect(body).toHaveLength(3);
+ expect(body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ email: 'admin@immich.cloud' }),
+ expect.objectContaining({ email: 'user2@immich.cloud' }),
+ expect.objectContaining({ email: 'user3@immich.cloud' }),
+ ])
+ );
});
it('should include deleted users', async () => {
- const user1 = await apiUtils.userSetup(
- admin.accessToken,
- createUserDto.user1
- );
- await deleteUser(
- { id: user1.userId },
- { headers: asBearerAuth(admin.accessToken) }
- );
-
const { status, body } = await request(app)
.get(`/user`)
.query({ isAll: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
- expect(body).toHaveLength(2);
- expect(body[0]).toMatchObject({
- id: user1.userId,
- email: 'user1@immich.cloud',
- deletedAt: expect.any(String),
- });
- expect(body[1]).toMatchObject({
- id: admin.userId,
- email: 'admin@immich.cloud',
- });
+ expect(body).toHaveLength(4);
+ expect(body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ email: 'admin@immich.cloud' }),
+ expect.objectContaining({ email: 'user1@immich.cloud' }),
+ expect.objectContaining({ email: 'user2@immich.cloud' }),
+ expect.objectContaining({ email: 'user3@immich.cloud' }),
+ ])
+ );
});
});
@@ -149,13 +148,13 @@ describe('/server-info', () => {
.post(`/user`)
.send({
isAdmin: true,
- email: 'user1@immich.cloud',
- password: 'Password123',
+ email: 'user4@immich.cloud',
+ password: 'password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
- email: 'user1@immich.cloud',
+ email: 'user4@immich.cloud',
isAdmin: false,
shouldChangePassword: true,
});
@@ -181,18 +180,9 @@ describe('/server-info', () => {
});
describe('DELETE /user/:id', () => {
- let userToDelete: UserResponseDto;
-
- beforeEach(async () => {
- userToDelete = await createUser(
- { createUserDto: createUserDto.user1 },
- { headers: asBearerAuth(admin.accessToken) }
- );
- });
-
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
- `/user/${userToDelete.id}`
+ `/user/${userToDelete.userId}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -200,12 +190,12 @@ describe('/server-info', () => {
it('should delete user', async () => {
const { status, body } = await request(app)
- .delete(`/user/${userToDelete.id}`)
+ .delete(`/user/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
- expect(body).toEqual({
- ...userToDelete,
+ expect(body).toMatchObject({
+ id: userToDelete.userId,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
@@ -231,14 +221,9 @@ describe('/server-info', () => {
}
it('should not allow a non-admin to become an admin', async () => {
- const user = await apiUtils.userSetup(
- admin.accessToken,
- createUserDto.user1
- );
-
const { status, body } = await request(app)
.put(`/user`)
- .send({ isAdmin: true, id: user.userId })
+ .send({ isAdmin: true, id: nonAdmin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 6c6d3b725d..fbc0b43b31 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -1,24 +1,33 @@
import {
AssetResponseDto,
+ CreateAlbumDto,
CreateAssetDto,
CreateUserDto,
- LoginResponseDto,
+ PersonUpdateDto,
+ SharedLinkCreateDto,
+ createAlbum,
createApiKey,
+ createPerson,
+ createSharedLink,
createUser,
defaults,
login,
setAdminOnboarding,
signUpAdmin,
+ updatePerson,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
-import { spawn } from 'child_process';
+import { exec, spawn } from 'child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import path from 'node:path';
+import { promisify } from 'node:util';
import pg from 'pg';
import { loginDto, signupDto } from 'src/fixtures';
import request from 'supertest';
+const execPromise = promisify(exec);
+
export const app = 'http://127.0.0.1:2283/api';
const directoryExists = (directory: string) =>
@@ -29,6 +38,9 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
+const serverContainerName = 'immich-e2e-server';
+const uploadMediaDir = '/usr/src/app/upload/upload';
+
if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
@@ -44,8 +56,45 @@ export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
let client: pg.Client | null = null;
-export const dbUtils = {
+export const fileUtils = {
reset: async () => {
+ await execPromise(
+ `docker exec -i "${serverContainerName}" rm -R "${uploadMediaDir}"`
+ );
+ },
+};
+
+export const dbUtils = {
+ createFace: async ({
+ assetId,
+ personId,
+ }: {
+ assetId: string;
+ personId: string;
+ }) => {
+ if (!client) {
+ return;
+ }
+
+ const vector = Array.from({ length: 512 }, Math.random);
+ const embedding = `[${vector.join(',')}]`;
+
+ await client.query(
+ 'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)',
+ [assetId, personId, embedding]
+ );
+ },
+ setPersonThumbnail: async (personId: string) => {
+ if (!client) {
+ return;
+ }
+
+ await client.query(
+ `UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`,
+ [personId]
+ );
+ },
+ reset: async (tables?: string[]) => {
try {
if (!client) {
client = new pg.Client(
@@ -54,14 +103,20 @@ export const dbUtils = {
await client.connect();
}
- for (const table of [
+ tables = tables || [
+ 'shared_links',
+ 'person',
'albums',
'assets',
+ 'asset_faces',
+ 'activity',
'api_keys',
'user_token',
'users',
'system_metadata',
- ]) {
+ ];
+
+ for (const table of tables) {
await client.query(`DELETE FROM ${table} CASCADE;`);
}
} catch (error) {
@@ -144,6 +199,11 @@ export const apiUtils = {
{ headers: asBearerAuth(accessToken) }
);
},
+ createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
+ createAlbum(
+ { createAlbumDto: dto },
+ { headers: asBearerAuth(accessToken) }
+ ),
createAsset: async (
accessToken: string,
dto?: Omit
@@ -165,6 +225,20 @@ export const apiUtils = {
return body as AssetResponseDto;
},
+ createPerson: async (accessToken: string, dto: PersonUpdateDto) => {
+ // TODO fix createPerson to accept a body
+ const { id } = await createPerson({ headers: asBearerAuth(accessToken) });
+ await dbUtils.setPersonThumbnail(id);
+ return updatePerson(
+ { id, personUpdateDto: dto },
+ { headers: asBearerAuth(accessToken) }
+ );
+ },
+ createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
+ createSharedLink(
+ { sharedLinkCreateDto: dto },
+ { headers: asBearerAuth(accessToken) }
+ ),
};
export const cliUtils = {
diff --git a/machine-learning/ann/__init__.py b/machine-learning/ann/__init__.py
index 0793d1011b..e69de29bb2 100644
--- a/machine-learning/ann/__init__.py
+++ b/machine-learning/ann/__init__.py
@@ -1 +0,0 @@
-from .ann import Ann, is_available
diff --git a/machine-learning/ann/ann.py b/machine-learning/ann/ann.py
index 94f665bfc7..148d5ba101 100644
--- a/machine-learning/ann/ann.py
+++ b/machine-learning/ann/ann.py
@@ -32,8 +32,7 @@ T = TypeVar("T", covariant=True)
class Newable(Protocol[T]):
- def new(self) -> None:
- ...
+ def new(self) -> None: ...
class _Singleton(type, Newable[T]):
diff --git a/machine-learning/app/models/base.py b/machine-learning/app/models/base.py
index 6097c7c987..6909a935c3 100644
--- a/machine-learning/app/models/base.py
+++ b/machine-learning/app/models/base.py
@@ -1,18 +1,16 @@
from __future__ import annotations
+import os
from abc import ABC, abstractmethod
from pathlib import Path
from shutil import rmtree
from typing import Any
-import onnx
import onnxruntime as ort
from huggingface_hub import snapshot_download
-from onnx.shape_inference import infer_shapes
-from onnx.tools.update_model_dims import update_inputs_outputs_dims
import ann.ann
-from app.models.constants import STATIC_INPUT_PROVIDERS, SUPPORTED_PROVIDERS
+from app.models.constants import SUPPORTED_PROVIDERS
from ..config import get_cache_dir, get_hf_model_name, log, settings
from ..schemas import ModelRuntime, ModelType
@@ -113,63 +111,25 @@ class InferenceModel(ABC):
)
model_path = onnx_path
- if any(provider in STATIC_INPUT_PROVIDERS for provider in self.providers):
- static_path = model_path.parent / "static_1" / "model.onnx"
- static_path.parent.mkdir(parents=True, exist_ok=True)
- if not static_path.is_file():
- self._convert_to_static(model_path, static_path)
- model_path = static_path
-
match model_path.suffix:
case ".armnn":
session = AnnSession(model_path)
case ".onnx":
- session = ort.InferenceSession(
- model_path.as_posix(),
- sess_options=self.sess_options,
- providers=self.providers,
- provider_options=self.provider_options,
- )
+ cwd = os.getcwd()
+ try:
+ os.chdir(model_path.parent)
+ session = ort.InferenceSession(
+ model_path.as_posix(),
+ sess_options=self.sess_options,
+ providers=self.providers,
+ provider_options=self.provider_options,
+ )
+ finally:
+ os.chdir(cwd)
case _:
raise ValueError(f"Unsupported model file type: {model_path.suffix}")
return session
- def _convert_to_static(self, source_path: Path, target_path: Path) -> None:
- inferred = infer_shapes(onnx.load(source_path))
- inputs = self._get_static_dims(inferred.graph.input)
- outputs = self._get_static_dims(inferred.graph.output)
-
- # check_model gets called in update_inputs_outputs_dims and doesn't work for large models
- check_model = onnx.checker.check_model
- try:
-
- def check_model_stub(*args: Any, **kwargs: Any) -> None:
- pass
-
- onnx.checker.check_model = check_model_stub
- updated_model = update_inputs_outputs_dims(inferred, inputs, outputs)
- finally:
- onnx.checker.check_model = check_model
-
- onnx.save(
- updated_model,
- target_path,
- save_as_external_data=True,
- all_tensors_to_one_file=False,
- size_threshold=1048576,
- )
-
- def _get_static_dims(self, graph_io: Any, dim_size: int = 1) -> dict[str, list[int]]:
- return {
- field.name: [
- d.dim_value if d.HasField("dim_value") else dim_size
- for shape in field.type.ListFields()
- if (dim := shape[1].shape.dim)
- for d in dim
- ]
- for field in graph_io
- }
-
@property
def model_type(self) -> ModelType:
return self._model_type
diff --git a/machine-learning/app/models/constants.py b/machine-learning/app/models/constants.py
index 18965d2b1d..b112e9279d 100644
--- a/machine-learning/app/models/constants.py
+++ b/machine-learning/app/models/constants.py
@@ -54,9 +54,6 @@ _INSIGHTFACE_MODELS = {
SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"]
-STATIC_INPUT_PROVIDERS = ["OpenVINOExecutionProvider"]
-
-
def is_openclip(model_name: str) -> bool:
return clean_name(model_name) in _OPENCLIP_MODELS
diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py
index cf941c1bbf..e25099e67e 100644
--- a/machine-learning/app/test_main.py
+++ b/machine-learning/app/test_main.py
@@ -1,4 +1,5 @@
import json
+import os
from io import BytesIO
from pathlib import Path
from random import randint
@@ -237,12 +238,12 @@ class TestBase:
mock_model_path.is_file.return_value = True
mock_model_path.suffix = ".armnn"
mock_model_path.with_suffix.return_value = mock_model_path
- mock_session = mocker.patch("app.models.base.AnnSession")
+ mock_ann = mocker.patch("app.models.base.AnnSession")
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_model_path)
- mock_session.assert_called_once()
+ mock_ann.assert_called_once()
def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None:
mock_armnn_path = mocker.Mock()
@@ -256,6 +257,7 @@ class TestBase:
mock_ann = mocker.patch("app.models.base.AnnSession")
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
+ mocker.patch("app.models.base.os.chdir")
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_armnn_path)
@@ -278,6 +280,26 @@ class TestBase:
mock_ann.assert_not_called()
mock_ort.assert_not_called()
+ def test_make_session_changes_cwd(self, mocker: MockerFixture) -> None:
+ mock_model_path = mocker.Mock()
+ mock_model_path.is_file.return_value = True
+ mock_model_path.suffix = ".onnx"
+ mock_model_path.parent = "model_parent"
+ mock_model_path.with_suffix.return_value = mock_model_path
+ mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
+ mock_chdir = mocker.patch("app.models.base.os.chdir")
+
+ encoder = OpenCLIPEncoder("ViT-B-32__openai")
+ encoder._make_session(mock_model_path)
+
+ mock_chdir.assert_has_calls(
+ [
+ mock.call(mock_model_path.parent),
+ mock.call(os.getcwd()),
+ ]
+ )
+ mock_ort.assert_called_once()
+
def test_download(self, mocker: MockerFixture) -> None:
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")
diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock
index 160b0b8e46..c8ae6c7410 100644
--- a/machine-learning/poetry.lock
+++ b/machine-learning/poetry.lock
@@ -64,33 +64,33 @@ trio = ["trio (>=0.23)"]
[[package]]
name = "black"
-version = "24.1.1"
+version = "24.2.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
- {file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"},
- {file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"},
- {file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"},
- {file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"},
- {file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"},
- {file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"},
- {file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"},
- {file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"},
- {file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"},
- {file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"},
- {file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"},
- {file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"},
- {file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"},
- {file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"},
- {file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"},
- {file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"},
- {file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"},
- {file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"},
- {file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"},
- {file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"},
- {file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"},
- {file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"},
+ {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"},
+ {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"},
+ {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"},
+ {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"},
+ {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"},
+ {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"},
+ {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"},
+ {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"},
+ {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"},
+ {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"},
+ {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"},
+ {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"},
+ {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"},
+ {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"},
+ {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"},
+ {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"},
+ {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"},
+ {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"},
+ {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"},
+ {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"},
+ {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"},
+ {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"},
]
[package.dependencies]
@@ -2101,61 +2101,61 @@ numpy = [
[[package]]
name = "orjson"
-version = "3.9.13"
+version = "3.9.14"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.8"
files = [
- {file = "orjson-3.9.13-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fa6b67f8bef277c2a4aadd548d58796854e7d760964126c3209b19bccc6a74f1"},
- {file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b812417199eeb169c25f67815cfb66fd8de7ff098bf57d065e8c1943a7ba5c8f"},
- {file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ccd5bd222e5041069ad9d9868ab59e6dbc53ecde8d8c82b919954fbba43b46b"},
- {file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaaf80957c38e9d3f796f355a80fad945e72cd745e6b64c210e635b7043b673e"},
- {file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60da7316131185d0110a1848e9ad15311e6c8938ee0b5be8cbd7261e1d80ee8f"},
- {file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b98cd948372f0eb219bc309dee4633db1278687161e3280d9e693b6076951d2"},
- {file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3869d65561f10071d3e7f35ae58fd377056f67d7aaed5222f318390c3ad30339"},
- {file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43fd6036b16bb6742d03dae62f7bdf8214d06dea47e4353cde7e2bd1358d186f"},
- {file = "orjson-3.9.13-cp310-none-win32.whl", hash = "sha256:0d3ba9d88e20765335260d7b25547d7c571eee2b698200f97afa7d8c7cd668fc"},
- {file = "orjson-3.9.13-cp310-none-win_amd64.whl", hash = "sha256:6e47153db080f5e87e8ba638f1a8b18995eede6b0abb93964d58cf11bcea362f"},
- {file = "orjson-3.9.13-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4584e8eb727bc431baaf1bf97e35a1d8a0109c924ec847395673dfd5f4ef6d6f"},
- {file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f37f0cdd026ef777a4336e599d8194c8357fc14760c2a5ddcfdf1965d45504b"},
- {file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d714595d81efab11b42bccd119977d94b25d12d3a806851ff6bfd286a4bce960"},
- {file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9171e8e1a1f221953e38e84ae0abffe8759002fd8968106ee379febbb5358b33"},
- {file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ab9dbdec3f13f3ea6f937564ce21651844cfbf2725099f2f490426acf683c23"},
- {file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:811ac076855e33e931549340288e0761873baf29276ad00f221709933c644330"},
- {file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:860d0f5b42d0c0afd73fa4177709f6e1b966ba691fcd72175affa902052a81d6"},
- {file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:838b898e8c1f26eb6b8d81b180981273f6f5110c76c22c384979aca854194f1b"},
- {file = "orjson-3.9.13-cp311-none-win32.whl", hash = "sha256:d3222db9df629ef3c3673124f2e05fb72bc4a320c117e953fec0d69dde82e36d"},
- {file = "orjson-3.9.13-cp311-none-win_amd64.whl", hash = "sha256:978117122ca4cc59b28af5322253017f6c5fc03dbdda78c7f4b94ae984c8dd43"},
- {file = "orjson-3.9.13-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:031df1026c7ea8303332d78711f180231e3ae8b564271fb748a03926587c5546"},
- {file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd9a2101d04e85086ea6198786a3f016e45475f800712e6833e14bf9ce2832f"},
- {file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:446d9ad04204e79229ae19502daeea56479e55cbc32634655d886f5a39e91b44"},
- {file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b57c0954a9fdd2b05b9cec0f5a12a0bdce5bf021a5b3b09323041613972481ab"},
- {file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:266e55c83f81248f63cc93d11c5e3a53df49a5d2598fa9e9db5f99837a802d5d"},
- {file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31372ba3a9fe8ad118e7d22fba46bbc18e89039e3bfa89db7bc8c18ee722dca8"},
- {file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3b0c4da61f39899561e08e571f54472a09fa71717d9797928af558175ae5243"},
- {file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cc03a35bfc71c8ebf96ce49b82c2a7be6af4b3cd3ac34166fdb42ac510bbfff"},
- {file = "orjson-3.9.13-cp312-none-win_amd64.whl", hash = "sha256:49b7e3fe861cb246361825d1a238f2584ed8ea21e714bf6bb17cebb86772e61c"},
- {file = "orjson-3.9.13-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:62e9a99879c4d5a04926ac2518a992134bfa00d546ea5a4cae4b9be454d35a22"},
- {file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d92a3e835a5100f1d5b566fff79217eab92223ca31900dba733902a182a35ab0"},
- {file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23f21faf072ed3b60b5954686f98157e073f6a8068eaa58dbde83e87212eda84"},
- {file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:828c502bb261588f7de897e06cb23c4b122997cb039d2014cb78e7dabe92ef0c"},
- {file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16946d095212a3dec552572c5d9bca7afa40f3116ad49695a397be07d529f1fa"},
- {file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3deadd8dc0e9ff844b5b656fa30a48dbee1c3b332d8278302dd9637f6b09f627"},
- {file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9b1b5adc5adf596c59dca57156b71ad301d73956f5bab4039b0e34dbf50b9fa0"},
- {file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ddc089315d030c54f0f03fb38286e2667c05009a78d659f108a8efcfbdf2e585"},
- {file = "orjson-3.9.13-cp38-none-win32.whl", hash = "sha256:ae77275a28667d9c82d4522b681504642055efa0368d73108511647c6499b31c"},
- {file = "orjson-3.9.13-cp38-none-win_amd64.whl", hash = "sha256:730385fdb99a21fce9bb84bb7fcbda72c88626facd74956bda712834b480729d"},
- {file = "orjson-3.9.13-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7e8e4a571d958910272af8d53a9cbe6599f9f5fd496a1bc51211183bb2072cbd"},
- {file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfad553a36548262e7da0f3a7464270e13900b898800fb571a5d4b298c3f8356"},
- {file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d691c44604941945b00e0a13b19a7d9c1a19511abadf0080f373e98fdeb6b31"},
- {file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8c83718346de08d68b3cb1105c5d91e5fc39885d8610fdda16613d4e3941459"},
- {file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ef57a53bfc2091a7cd50a640d9ae866bd7d92a5225a1bab6baa60ef62583f2"},
- {file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9156b96afa38db71344522f5517077eaedf62fcd2c9148392ff93d801128809c"},
- {file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31fb66b41fb2c4c817d9610f0bc7d31345728d7b5295ac78b63603407432a2b2"},
- {file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8a730bf07feacb0863974e67b206b7c503a62199de1cece2eb0d4c233ec29c11"},
- {file = "orjson-3.9.13-cp39-none-win32.whl", hash = "sha256:5ef58869f3399acbbe013518d8b374ee9558659eef14bca0984f67cb1fbd3c37"},
- {file = "orjson-3.9.13-cp39-none-win_amd64.whl", hash = "sha256:9bcf56efdb83244cde070e82a69c0f03c47c235f0a5cb6c81d9da23af7fbaae4"},
- {file = "orjson-3.9.13.tar.gz", hash = "sha256:fc6bc65b0cf524ee042e0bc2912b9206ef242edfba7426cf95763e4af01f527a"},
+ {file = "orjson-3.9.14-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:793f6c9448ab6eb7d4974b4dde3f230345c08ca6c7995330fbceeb43a5c8aa5e"},
+ {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bc7928d161840096adc956703494b5c0193ede887346f028216cac0af87500"},
+ {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58b36f54da759602d8e2f7dad958752d453dfe2c7122767bc7f765e17dc59959"},
+ {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:abcda41ecdc950399c05eff761c3de91485d9a70d8227cb599ad3a66afe93bcc"},
+ {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df76ecd17b1b3627bddfd689faaf206380a1a38cc9f6c4075bd884eaedcf46c2"},
+ {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d450a8e0656efb5d0fcb062157b918ab02dcca73278975b4ee9ea49e2fcf5bd5"},
+ {file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:95c03137b0cf66517c8baa65770507a756d3a89489d8ecf864ea92348e1beabe"},
+ {file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20837e10835c98973673406d6798e10f821e7744520633811a5a3d809762d8cc"},
+ {file = "orjson-3.9.14-cp310-none-win32.whl", hash = "sha256:1f7b6f3ef10ae8e3558abb729873d033dbb5843507c66b1c0767e32502ba96bb"},
+ {file = "orjson-3.9.14-cp310-none-win_amd64.whl", hash = "sha256:ea890e6dc1711aeec0a33b8520e395c2f3d59ead5b4351a788e06bf95fc7ba81"},
+ {file = "orjson-3.9.14-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c19009ff37f033c70acd04b636380379499dac2cba27ae7dfc24f304deabbc81"},
+ {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19cdea0664aec0b7f385be84986d4defd3334e9c3c799407686ee1c26f7b8251"},
+ {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:135d518f73787ce323b1a5e21fb854fe22258d7a8ae562b81a49d6c7f826f2a3"},
+ {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2cf1d0557c61c75e18cf7d69fb689b77896e95553e212c0cc64cf2087944b84"},
+ {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7c11667421df2d8b18b021223505dcc3ee51be518d54e4dc49161ac88ac2b87"},
+ {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eefc41ba42e75ed88bc396d8fe997beb20477f3e7efa000cd7a47eda452fbb2"},
+ {file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:917311d6a64d1c327c0dfda1e41f3966a7fb72b11ca7aa2e7a68fcccc7db35d9"},
+ {file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4dc1c132259b38d12c6587d190cd09cd76e3b5273ce71fe1372437b4cbc65f6f"},
+ {file = "orjson-3.9.14-cp311-none-win32.whl", hash = "sha256:6f39a10408478f4c05736a74da63727a1ae0e83e3533d07b19443400fe8591ca"},
+ {file = "orjson-3.9.14-cp311-none-win_amd64.whl", hash = "sha256:26280a7fcb62d8257f634c16acebc3bec626454f9ab13558bbf7883b9140760e"},
+ {file = "orjson-3.9.14-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:08e722a8d06b13b67a51f247a24938d1a94b4b3862e40e0eef3b2e98c99cd04c"},
+ {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2591faa0c031cf3f57e5bce1461cfbd6160f3f66b5a72609a130924917cb07d"},
+ {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2450d87dd7b4f277f4c5598faa8b49a0c197b91186c47a2c0b88e15531e4e3e"},
+ {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90903d2908158a2c9077a06f11e27545de610af690fb178fd3ba6b32492d4d1c"},
+ {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce6f095eef0026eae76fc212f20f786011ecf482fc7df2f4c272a8ae6dd7b1ef"},
+ {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:751250a31fef2bac05a2da2449aae7142075ea26139271f169af60456d8ad27a"},
+ {file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a1af21160a38ee8be3f4fcf24ee4b99e6184cadc7f915d599f073f478a94d2c"},
+ {file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:449bf090b2aa4e019371d7511a6ea8a5a248139205c27d1834bb4b1e3c44d936"},
+ {file = "orjson-3.9.14-cp312-none-win_amd64.whl", hash = "sha256:a603161318ff699784943e71f53899983b7dee571b4dd07c336437c9c5a272b0"},
+ {file = "orjson-3.9.14-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:814f288c011efdf8f115c5ebcc1ab94b11da64b207722917e0ceb42f52ef30a3"},
+ {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88cafb100af68af3b9b29b5ccd09fdf7a48c63327916c8c923a94c336d38dd3"},
+ {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba3518b999f88882ade6686f1b71e207b52e23546e180499be5bbb63a2f9c6e6"},
+ {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978f416bbff9da8d2091e3cf011c92da68b13f2c453dcc2e8109099b2a19d234"},
+ {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75fc593cf836f631153d0e21beaeb8d26e144445c73645889335c2247fcd71a0"},
+ {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d1528db3c7554f9d6eeb09df23cb80dd5177ec56eeb55cc5318826928de506"},
+ {file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7183cc68ee2113b19b0b8714221e5e3b07b3ba10ca2bb108d78fd49cefaae101"},
+ {file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df3266d54246cb56b8bb17fa908660d2a0f2e3f63fbc32451ffc1b1505051d07"},
+ {file = "orjson-3.9.14-cp38-none-win32.whl", hash = "sha256:7913079b029e1b3501854c9a78ad938ed40d61fe09bebab3c93e60ff1301b189"},
+ {file = "orjson-3.9.14-cp38-none-win_amd64.whl", hash = "sha256:29512eb925b620e5da2fd7585814485c67cc6ba4fe739a0a700c50467a8a8065"},
+ {file = "orjson-3.9.14-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bf597530544db27a8d76aced49cfc817ee9503e0a4ebf0109cd70331e7bbe0c"},
+ {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac650d49366fa41fe702e054cb560171a8634e2865537e91f09a8d05ea5b1d37"},
+ {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:236230433a9a4968ab895140514c308fdf9f607cb8bee178a04372b771123860"},
+ {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3014ccbda9be0b1b5f8ea895121df7e6524496b3908f4397ff02e923bcd8f6dd"},
+ {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac0c7eae7ad3a223bde690565442f8a3d620056bd01196f191af8be58a5248e1"},
+ {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca33fdd0b38839b01912c57546d4f412ba7bfa0faf9bf7453432219aec2df07"},
+ {file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f75823cc1674a840a151e999a7dfa0d86c911150dd6f951d0736ee9d383bf415"},
+ {file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f52ac2eb49e99e7373f62e2a68428c6946cda52ce89aa8fe9f890c7278e2d3a"},
+ {file = "orjson-3.9.14-cp39-none-win32.whl", hash = "sha256:0572f174f50b673b7df78680fb52cd0087a8585a6d06d295a5f790568e1064c6"},
+ {file = "orjson-3.9.14-cp39-none-win_amd64.whl", hash = "sha256:ab90c02cb264250b8a58cedcc72ed78a4a257d956c8d3c8bebe9751b818dfad8"},
+ {file = "orjson-3.9.14.tar.gz", hash = "sha256:06fb40f8e49088ecaa02f1162581d39e2cf3fd9dbbfe411eb2284147c99bad79"},
]
[[package]]
@@ -3096,121 +3096,121 @@ all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib"
[[package]]
name = "tokenizers"
-version = "0.15.1"
+version = "0.15.2"
description = ""
optional = false
python-versions = ">=3.7"
files = [
- {file = "tokenizers-0.15.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:32c9491dd1bcb33172c26b454dbd607276af959b9e78fa766e2694cafab3103c"},
- {file = "tokenizers-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29a1b784b870a097e7768f8c20c2dd851e2c75dad3efdae69a79d3e7f1d614d5"},
- {file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0049fbe648af04148b08cb211994ce8365ee628ce49724b56aaefd09a3007a78"},
- {file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e84b3c235219e75e24de6b71e6073cd2c8d740b14d88e4c6d131b90134e3a338"},
- {file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8cc575769ea11d074308c6d71cb10b036cdaec941562c07fc7431d956c502f0e"},
- {file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bf28f299c4158e6d0b5eaebddfd500c4973d947ffeaca8bcbe2e8c137dff0b"},
- {file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:506555f98361db9c74e1323a862d77dcd7d64c2058829a368bf4159d986e339f"},
- {file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7061b0a28ade15906f5b2ec8c48d3bdd6e24eca6b427979af34954fbe31d5cef"},
- {file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ed5e35507b7a0e2aac3285c4f5e37d4ec5cfc0e5825b862b68a0aaf2757af52"},
- {file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c9df9247df0de6509dd751b1c086e5f124b220133b5c883bb691cb6fb3d786f"},
- {file = "tokenizers-0.15.1-cp310-none-win32.whl", hash = "sha256:dd999af1b4848bef1b11d289f04edaf189c269d5e6afa7a95fa1058644c3f021"},
- {file = "tokenizers-0.15.1-cp310-none-win_amd64.whl", hash = "sha256:39d06a57f7c06940d602fad98702cf7024c4eee7f6b9fe76b9f2197d5a4cc7e2"},
- {file = "tokenizers-0.15.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8ad034eb48bf728af06915e9294871f72fcc5254911eddec81d6df8dba1ce055"},
- {file = "tokenizers-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea9ede7c42f8fa90f31bfc40376fd91a7d83a4aa6ad38e6076de961d48585b26"},
- {file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b85d6fe1a20d903877aa0ef32ef6b96e81e0e48b71c206d6046ce16094de6970"},
- {file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a7d44f656320137c7d643b9c7dcc1814763385de737fb98fd2643880910f597"},
- {file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd244bd0793cdacf27ee65ec3db88c21f5815460e8872bbeb32b040469d6774e"},
- {file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3f4a36e371b3cb1123adac8aeeeeab207ad32f15ed686d9d71686a093bb140"},
- {file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2921a53966afb29444da98d56a6ccbef23feb3b0c0f294b4e502370a0a64f25"},
- {file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f49068cf51f49c231067f1a8c9fc075ff960573f6b2a956e8e1b0154fb638ea5"},
- {file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0ab1a22f20eaaab832ab3b00a0709ca44a0eb04721e580277579411b622c741c"},
- {file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:671268f24b607c4adc6fa2b5b580fd4211b9f84b16bd7f46d62f8e5be0aa7ba4"},
- {file = "tokenizers-0.15.1-cp311-none-win32.whl", hash = "sha256:a4f03e33d2bf7df39c8894032aba599bf90f6f6378e683a19d28871f09bb07fc"},
- {file = "tokenizers-0.15.1-cp311-none-win_amd64.whl", hash = "sha256:30f689537bcc7576d8bd4daeeaa2cb8f36446ba2f13f421b173e88f2d8289c4e"},
- {file = "tokenizers-0.15.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f3a379dd0898a82ea3125e8f9c481373f73bffce6430d4315f0b6cd5547e409"},
- {file = "tokenizers-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d870ae58bba347d38ac3fc8b1f662f51e9c95272d776dd89f30035c83ee0a4f"},
- {file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d6d28e0143ec2e253a8a39e94bf1d24776dbe73804fa748675dbffff4a5cd6d8"},
- {file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61ae9ac9f44e2da128ee35db69489883b522f7abe033733fa54eb2de30dac23d"},
- {file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8e322a47e29128300b3f7749a03c0ec2bce0a3dc8539ebff738d3f59e233542"},
- {file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:760334f475443bc13907b1a8e1cb0aeaf88aae489062546f9704dce6c498bfe2"},
- {file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b173753d4aca1e7d0d4cb52b5e3ffecfb0ca014e070e40391b6bb4c1d6af3f2"},
- {file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1f13d457c8f0ab17e32e787d03470067fe8a3b4d012e7cc57cb3264529f4a"},
- {file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:425b46ceff4505f20191df54b50ac818055d9d55023d58ae32a5d895b6f15bb0"},
- {file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:681ac6ba3b4fdaf868ead8971221a061f580961c386e9732ea54d46c7b72f286"},
- {file = "tokenizers-0.15.1-cp312-none-win32.whl", hash = "sha256:f2272656063ccfba2044df2115095223960d80525d208e7a32f6c01c351a6f4a"},
- {file = "tokenizers-0.15.1-cp312-none-win_amd64.whl", hash = "sha256:9abe103203b1c6a2435d248d5ff4cceebcf46771bfbc4957a98a74da6ed37674"},
- {file = "tokenizers-0.15.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2ce9ed5c8ef26b026a66110e3c7b73d93ec2d26a0b1d0ea55ddce61c0e5f446f"},
- {file = "tokenizers-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89b24d366137986c3647baac29ef902d2d5445003d11c30df52f1bd304689aeb"},
- {file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0faebedd01b413ab777ca0ee85914ed8b031ea5762ab0ea60b707ce8b9be6842"},
- {file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdbd9dfcdad4f3b95d801f768e143165165055c18e44ca79a8a26de889cd8e85"},
- {file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97194324c12565b07e9993ca9aa813b939541185682e859fb45bb8d7d99b3193"},
- {file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485e43e2cc159580e0d83fc919ec3a45ae279097f634b1ffe371869ffda5802c"},
- {file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191d084d60e3589d6420caeb3f9966168269315f8ec7fbc3883122dc9d99759d"},
- {file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01c28cc8d7220634a75b14c53f4fc9d1b485f99a5a29306a999c115921de2897"},
- {file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:325212027745d3f8d5d5006bb9e5409d674eb80a184f19873f4f83494e1fdd26"},
- {file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3c5573603c36ce12dbe318bcfb490a94cad2d250f34deb2f06cb6937957bbb71"},
- {file = "tokenizers-0.15.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:1441161adb6d71a15a630d5c1d8659d5ebe41b6b209586fbeea64738e58fcbb2"},
- {file = "tokenizers-0.15.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:382a8d0c31afcfb86571afbfefa37186df90865ce3f5b731842dab4460e53a38"},
- {file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e76959783e3f4ec73b3f3d24d4eec5aa9225f0bee565c48e77f806ed1e048f12"},
- {file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:401df223e5eb927c5961a0fc6b171818a2bba01fb36ef18c3e1b69b8cd80e591"},
- {file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52606c233c759561a16e81b2290a7738c3affac7a0b1f0a16fe58dc22e04c7d"},
- {file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72c658bbe5a05ed8bc2ac5ad782385bfd743ffa4bc87d9b5026341e709c6f44"},
- {file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25f5643a2f005c42f0737a326c6c6bdfedfdc9a994b10a1923d9c3e792e4d6a6"},
- {file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5b6f633999d6b42466bbfe21be2e26ad1760b6f106967a591a41d8cbca980e"},
- {file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ceb5c9ad11a015150b545c1a11210966a45b8c3d68a942e57cf8938c578a77ca"},
- {file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bedd4ce0c4872db193444c395b11c7697260ce86a635ab6d48102d76be07d324"},
- {file = "tokenizers-0.15.1-cp37-none-win32.whl", hash = "sha256:cd6caef6c14f5ed6d35f0ddb78eab8ca6306d0cd9870330bccff72ad014a6f42"},
- {file = "tokenizers-0.15.1-cp37-none-win_amd64.whl", hash = "sha256:d2bd7af78f58d75a55e5df61efae164ab9200c04b76025f9cc6eeb7aff3219c2"},
- {file = "tokenizers-0.15.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:59b3ca6c02e0bd5704caee274978bd055de2dff2e2f39dadf536c21032dfd432"},
- {file = "tokenizers-0.15.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:48fe21b67c22583bed71933a025fd66b1f5cfae1baefa423c3d40379b5a6e74e"},
- {file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3d190254c66a20fb1efbdf035e6333c5e1f1c73b1f7bfad88f9c31908ac2c2c4"},
- {file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef90c8f5abf17d48d6635f5fd92ad258acd1d0c2d920935c8bf261782cfe7c8"},
- {file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fac011ef7da3357aa7eb19efeecf3d201ede9618f37ddedddc5eb809ea0963ca"},
- {file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:574ec5b3e71d1feda6b0ecac0e0445875729b4899806efbe2b329909ec75cb50"},
- {file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aca16c3c0637c051a59ea99c4253f16fbb43034fac849076a7e7913b2b9afd2d"},
- {file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6f238fc2bbfd3e12e8529980ec1624c7e5b69d4e959edb3d902f36974f725a"},
- {file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:587e11a26835b73c31867a728f32ca8a93c9ded4a6cd746516e68b9d51418431"},
- {file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6456e7ad397352775e2efdf68a9ec5d6524bbc4543e926eef428d36de627aed4"},
- {file = "tokenizers-0.15.1-cp38-none-win32.whl", hash = "sha256:614f0da7dd73293214bd143e6221cafd3f7790d06b799f33a987e29d057ca658"},
- {file = "tokenizers-0.15.1-cp38-none-win_amd64.whl", hash = "sha256:a4fa0a20d9f69cc2bf1cfce41aa40588598e77ec1d6f56bf0eb99769969d1ede"},
- {file = "tokenizers-0.15.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8d3f18a45e0cf03ce193d5900460dc2430eec4e14c786e5d79bddba7ea19034f"},
- {file = "tokenizers-0.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:38dbd6c38f88ad7d5dc5d70c764415d38fe3bcd99dc81638b572d093abc54170"},
- {file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:777286b1f7e52de92aa4af49fe31046cfd32885d1bbaae918fab3bba52794c33"},
- {file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d4d550a3862a47dd249892d03a025e32286eb73cbd6bc887fb8fb64bc97165"},
- {file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eda68ce0344f35042ae89220b40a0007f721776b727806b5c95497b35714bb7"},
- {file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cd33d15f7a3a784c3b665cfe807b8de3c6779e060349bd5005bb4ae5bdcb437"},
- {file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a1aa370f978ac0bfb50374c3a40daa93fd56d47c0c70f0c79607fdac2ccbb42"},
- {file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:241482b940340fff26a2708cb9ba383a5bb8a2996d67a0ff2c4367bf4b86cc3a"},
- {file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:68f30b05f46a4d9aba88489eadd021904afe90e10a7950e28370d6e71b9db021"},
- {file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5a3c5d8025529670462b881b7b2527aacb6257398c9ec8e170070432c3ae3a82"},
- {file = "tokenizers-0.15.1-cp39-none-win32.whl", hash = "sha256:74d1827830f60a9d78da8f6d49a1fbea5422ce0eea42e2617877d23380a7efbc"},
- {file = "tokenizers-0.15.1-cp39-none-win_amd64.whl", hash = "sha256:9ff499923e4d6876d6b6a63ea84a56805eb35e91dd89b933a7aee0c56a3838c6"},
- {file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b3aa007a0f4408f62a8471bdaa3faccad644cbf2622639f2906b4f9b5339e8b8"},
- {file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f3d4176fa93d8b2070db8f3c70dc21106ae6624fcaaa334be6bdd3a0251e729e"},
- {file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d0e463655ef8b2064df07bd4a445ed7f76f6da3b286b4590812587d42f80e89"},
- {file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:089138fd0351b62215c462a501bd68b8df0e213edcf99ab9efd5dba7b4cb733e"},
- {file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e563ac628f5175ed08e950430e2580e544b3e4b606a0995bb6b52b3a3165728"},
- {file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:244dcc28c5fde221cb4373961b20da30097669005b122384d7f9f22752487a46"},
- {file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d82951d46052dddae1369e68ff799a0e6e29befa9a0b46e387ae710fd4daefb0"},
- {file = "tokenizers-0.15.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b14296bc9059849246ceb256ffbe97f8806a9b5d707e0095c22db312f4fc014"},
- {file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0309357bb9b6c8d86cdf456053479d7112074b470651a997a058cd7ad1c4ea57"},
- {file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083f06e9d8d01b70b67bcbcb7751b38b6005512cce95808be6bf34803534a7e7"},
- {file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85288aea86ada579789447f0dcec108ebef8da4b450037eb4813d83e4da9371e"},
- {file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:385e6fcb01e8de90c1d157ae2a5338b23368d0b1c4cc25088cdca90147e35d17"},
- {file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:60067edfcbf7d6cd448ac47af41ec6e84377efbef7be0c06f15a7c1dd069e044"},
- {file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f7e37f89acfe237d4eaf93c3b69b0f01f407a7a5d0b5a8f06ba91943ea3cf10"},
- {file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:6a63a15b523d42ebc1f4028e5a568013388c2aefa4053a263e511cb10aaa02f1"},
- {file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2417d9e4958a6c2fbecc34c27269e74561c55d8823bf914b422e261a11fdd5fd"},
- {file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8550974bace6210e41ab04231e06408cf99ea4279e0862c02b8d47e7c2b2828"},
- {file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:194ba82129b171bcd29235a969e5859a93e491e9b0f8b2581f500f200c85cfdd"},
- {file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1bfd95eef8b01e6c0805dbccc8eaf41d8c5a84f0cce72c0ab149fe76aae0bce6"},
- {file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b87a15dd72f8216b03c151e3dace00c75c3fe7b0ee9643c25943f31e582f1a34"},
- {file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6ac22f358a0c2a6c685be49136ce7ea7054108986ad444f567712cf274b34cd8"},
- {file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e9d1f046a9b9d9a95faa103f07db5921d2c1c50f0329ebba4359350ee02b18b"},
- {file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a0fd30a4b74485f6a7af89fffb5fb84d6d5f649b3e74f8d37f624cc9e9e97cf"},
- {file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e45dc206b9447fa48795a1247c69a1732d890b53e2cc51ba42bc2fefa22407"},
- {file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eaff56ef3e218017fa1d72007184401f04cb3a289990d2b6a0a76ce71c95f96"},
- {file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b41dc107e4a4e9c95934e79b025228bbdda37d9b153d8b084160e88d5e48ad6f"},
- {file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1922b8582d0c33488764bcf32e80ef6054f515369e70092729c928aae2284bc2"},
- {file = "tokenizers-0.15.1.tar.gz", hash = "sha256:c0a331d6d5a3d6e97b7f99f562cee8d56797180797bc55f12070e495e717c980"},
+ {file = "tokenizers-0.15.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:52f6130c9cbf70544287575a985bf44ae1bda2da7e8c24e97716080593638012"},
+ {file = "tokenizers-0.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:054c1cc9c6d68f7ffa4e810b3d5131e0ba511b6e4be34157aa08ee54c2f8d9ee"},
+ {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9b9b070fdad06e347563b88c278995735292ded1132f8657084989a4c84a6d5"},
+ {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea621a7eef4b70e1f7a4e84dd989ae3f0eeb50fc8690254eacc08acb623e82f1"},
+ {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7fd9a5141634fa3aa8d6b7be362e6ae1b4cda60da81388fa533e0b552c98fd"},
+ {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44f2a832cd0825295f7179eaf173381dc45230f9227ec4b44378322d900447c9"},
+ {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b9ec69247a23747669ec4b0ca10f8e3dfb3545d550258129bd62291aabe8605"},
+ {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b6a4c78da863ff26dbd5ad9a8ecc33d8a8d97b535172601cf00aee9d7ce9ce"},
+ {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5ab2a4d21dcf76af60e05af8063138849eb1d6553a0d059f6534357bce8ba364"},
+ {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a47acfac7e511f6bbfcf2d3fb8c26979c780a91e06fb5b9a43831b2c0153d024"},
+ {file = "tokenizers-0.15.2-cp310-none-win32.whl", hash = "sha256:064ff87bb6acdbd693666de9a4b692add41308a2c0ec0770d6385737117215f2"},
+ {file = "tokenizers-0.15.2-cp310-none-win_amd64.whl", hash = "sha256:3b919afe4df7eb6ac7cafd2bd14fb507d3f408db7a68c43117f579c984a73843"},
+ {file = "tokenizers-0.15.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:89cd1cb93e4b12ff39bb2d626ad77e35209de9309a71e4d3d4672667b4b256e7"},
+ {file = "tokenizers-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfed5c64e5be23d7ee0f0e98081a25c2a46b0b77ce99a4f0605b1ec43dd481fa"},
+ {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a907d76dcfda37023ba203ab4ceeb21bc5683436ebefbd895a0841fd52f6f6f2"},
+ {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ea60479de6fc7b8ae756b4b097572372d7e4032e2521c1bbf3d90c90a99ff0"},
+ {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48e2b9335be2bc0171df9281385c2ed06a15f5cf121c44094338306ab7b33f2c"},
+ {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:112a1dd436d2cc06e6ffdc0b06d55ac019a35a63afd26475205cb4b1bf0bfbff"},
+ {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4620cca5c2817177ee8706f860364cc3a8845bc1e291aaf661fb899e5d1c45b0"},
+ {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccd73a82751c523b3fc31ff8194702e4af4db21dc20e55b30ecc2079c5d43cb7"},
+ {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:107089f135b4ae7817affe6264f8c7a5c5b4fd9a90f9439ed495f54fcea56fb4"},
+ {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0ff110ecc57b7aa4a594396525a3451ad70988e517237fe91c540997c4e50e29"},
+ {file = "tokenizers-0.15.2-cp311-none-win32.whl", hash = "sha256:6d76f00f5c32da36c61f41c58346a4fa7f0a61be02f4301fd30ad59834977cc3"},
+ {file = "tokenizers-0.15.2-cp311-none-win_amd64.whl", hash = "sha256:cc90102ed17271cf0a1262babe5939e0134b3890345d11a19c3145184b706055"},
+ {file = "tokenizers-0.15.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f86593c18d2e6248e72fb91c77d413a815153b8ea4e31f7cd443bdf28e467670"},
+ {file = "tokenizers-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0774bccc6608eca23eb9d620196687c8b2360624619623cf4ba9dc9bd53e8b51"},
+ {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0222c5b7c9b26c0b4822a82f6a7011de0a9d3060e1da176f66274b70f846b98"},
+ {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3835738be1de66624fff2f4f6f6684775da4e9c00bde053be7564cbf3545cc66"},
+ {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0143e7d9dcd811855c1ce1ab9bf5d96d29bf5e528fd6c7824d0465741e8c10fd"},
+ {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db35825f6d54215f6b6009a7ff3eedee0848c99a6271c870d2826fbbedf31a38"},
+ {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f5e64b0389a2be47091d8cc53c87859783b837ea1a06edd9d8e04004df55a5c"},
+ {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e0480c452217edd35eca56fafe2029fb4d368b7c0475f8dfa3c5c9c400a7456"},
+ {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a33ab881c8fe70474980577e033d0bc9a27b7ab8272896e500708b212995d834"},
+ {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a308a607ca9de2c64c1b9ba79ec9a403969715a1b8ba5f998a676826f1a7039d"},
+ {file = "tokenizers-0.15.2-cp312-none-win32.whl", hash = "sha256:b8fcfa81bcb9447df582c5bc96a031e6df4da2a774b8080d4f02c0c16b42be0b"},
+ {file = "tokenizers-0.15.2-cp312-none-win_amd64.whl", hash = "sha256:38d7ab43c6825abfc0b661d95f39c7f8af2449364f01d331f3b51c94dcff7221"},
+ {file = "tokenizers-0.15.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:38bfb0204ff3246ca4d5e726e8cc8403bfc931090151e6eede54d0e0cf162ef0"},
+ {file = "tokenizers-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c861d35e8286a53e06e9e28d030b5a05bcbf5ac9d7229e561e53c352a85b1fc"},
+ {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:936bf3842db5b2048eaa53dade907b1160f318e7c90c74bfab86f1e47720bdd6"},
+ {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:620beacc3373277700d0e27718aa8b25f7b383eb8001fba94ee00aeea1459d89"},
+ {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2735ecbbf37e52db4ea970e539fd2d450d213517b77745114f92867f3fc246eb"},
+ {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:473c83c5e2359bb81b0b6fde870b41b2764fcdd36d997485e07e72cc3a62264a"},
+ {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968fa1fb3c27398b28a4eca1cbd1e19355c4d3a6007f7398d48826bbe3a0f728"},
+ {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:865c60ae6eaebdde7da66191ee9b7db52e542ed8ee9d2c653b6d190a9351b980"},
+ {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7c0d8b52664ab2d4a8d6686eb5effc68b78608a9008f086a122a7b2996befbab"},
+ {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f33dfbdec3784093a9aebb3680d1f91336c56d86cc70ddf88708251da1fe9064"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:d44ba80988ff9424e33e0a49445072ac7029d8c0e1601ad25a0ca5f41ed0c1d6"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:dce74266919b892f82b1b86025a613956ea0ea62a4843d4c4237be2c5498ed3a"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0ef06b9707baeb98b316577acb04f4852239d856b93e9ec3a299622f6084e4be"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73e2e74bbb07910da0d37c326869f34113137b23eadad3fc00856e6b3d9930c"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eeb12daf02a59e29f578a865f55d87cd103ce62bd8a3a5874f8fdeaa82e336b"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ba9f6895af58487ca4f54e8a664a322f16c26bbb442effd01087eba391a719e"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccec77aa7150e38eec6878a493bf8c263ff1fa8a62404e16c6203c64c1f16a26"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f40604f5042ff210ba82743dda2b6aa3e55aa12df4e9f2378ee01a17e2855e"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5645938a42d78c4885086767c70923abad047163d809c16da75d6b290cb30bbe"},
+ {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:05a77cbfebe28a61ab5c3891f9939cc24798b63fa236d84e5f29f3a85a200c00"},
+ {file = "tokenizers-0.15.2-cp37-none-win32.whl", hash = "sha256:361abdc068e8afe9c5b818769a48624687fb6aaed49636ee39bec4e95e1a215b"},
+ {file = "tokenizers-0.15.2-cp37-none-win_amd64.whl", hash = "sha256:7ef789f83eb0f9baeb4d09a86cd639c0a5518528f9992f38b28e819df397eb06"},
+ {file = "tokenizers-0.15.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4fe1f74a902bee74a3b25aff180fbfbf4f8b444ab37c4d496af7afd13a784ed2"},
+ {file = "tokenizers-0.15.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c4b89038a684f40a6b15d6b09f49650ac64d951ad0f2a3ea9169687bbf2a8ba"},
+ {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d05a1b06f986d41aed5f2de464c003004b2df8aaf66f2b7628254bcbfb72a438"},
+ {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:508711a108684111ec8af89d3a9e9e08755247eda27d0ba5e3c50e9da1600f6d"},
+ {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daa348f02d15160cb35439098ac96e3a53bacf35885072611cd9e5be7d333daa"},
+ {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:494fdbe5932d3416de2a85fc2470b797e6f3226c12845cadf054dd906afd0442"},
+ {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2d60f5246f4da9373f75ff18d64c69cbf60c3bca597290cea01059c336d2470"},
+ {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93268e788825f52de4c7bdcb6ebc1fcd4a5442c02e730faa9b6b08f23ead0e24"},
+ {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6fc7083ab404019fc9acafe78662c192673c1e696bd598d16dc005bd663a5cf9"},
+ {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e39b41e5531d6b2122a77532dbea60e171ef87a3820b5a3888daa847df4153"},
+ {file = "tokenizers-0.15.2-cp38-none-win32.whl", hash = "sha256:06cd0487b1cbfabefb2cc52fbd6b1f8d4c37799bd6c6e1641281adaa6b2504a7"},
+ {file = "tokenizers-0.15.2-cp38-none-win_amd64.whl", hash = "sha256:5179c271aa5de9c71712e31cb5a79e436ecd0d7532a408fa42a8dbfa4bc23fd9"},
+ {file = "tokenizers-0.15.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82f8652a74cc107052328b87ea8b34291c0f55b96d8fb261b3880216a9f9e48e"},
+ {file = "tokenizers-0.15.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02458bee6f5f3139f1ebbb6d042b283af712c0981f5bc50edf771d6b762d5e4f"},
+ {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9a09cd26cca2e1c349f91aa665309ddb48d71636370749414fbf67bc83c5343"},
+ {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158be8ea8554e5ed69acc1ce3fbb23a06060bd4bbb09029431ad6b9a466a7121"},
+ {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ddba9a2b0c8c81633eca0bb2e1aa5b3a15362b1277f1ae64176d0f6eba78ab1"},
+ {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ef5dd1d39797044642dbe53eb2bc56435308432e9c7907728da74c69ee2adca"},
+ {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:454c203164e07a860dbeb3b1f4a733be52b0edbb4dd2e5bd75023ffa8b49403a"},
+ {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cf6b7f1d4dc59af960e6ffdc4faffe6460bbfa8dce27a58bf75755ffdb2526d"},
+ {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2ef09bbc16519f6c25d0c7fc0c6a33a6f62923e263c9d7cca4e58b8c61572afb"},
+ {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c9a2ebdd2ad4ec7a68e7615086e633857c85e2f18025bd05d2a4399e6c5f7169"},
+ {file = "tokenizers-0.15.2-cp39-none-win32.whl", hash = "sha256:918fbb0eab96fe08e72a8c2b5461e9cce95585d82a58688e7f01c2bd546c79d0"},
+ {file = "tokenizers-0.15.2-cp39-none-win_amd64.whl", hash = "sha256:524e60da0135e106b254bd71f0659be9f89d83f006ea9093ce4d1fab498c6d0d"},
+ {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6a9b648a58281c4672212fab04e60648fde574877d0139cd4b4f93fe28ca8944"},
+ {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7c7d18b733be6bbca8a55084027f7be428c947ddf871c500ee603e375013ffba"},
+ {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:13ca3611de8d9ddfbc4dc39ef54ab1d2d4aaa114ac8727dfdc6a6ec4be017378"},
+ {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:237d1bf3361cf2e6463e6c140628e6406766e8b27274f5fcc62c747ae3c6f094"},
+ {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67a0fe1e49e60c664915e9fb6b0cb19bac082ab1f309188230e4b2920230edb3"},
+ {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4e022fe65e99230b8fd89ebdfea138c24421f91c1a4f4781a8f5016fd5cdfb4d"},
+ {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d857be2df69763362ac699f8b251a8cd3fac9d21893de129bc788f8baaef2693"},
+ {file = "tokenizers-0.15.2-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:708bb3e4283177236309e698da5fcd0879ce8fd37457d7c266d16b550bcbbd18"},
+ {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c35e09e9899b72a76e762f9854e8750213f67567787d45f37ce06daf57ca78"},
+ {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1257f4394be0d3b00de8c9e840ca5601d0a4a8438361ce9c2b05c7d25f6057b"},
+ {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02272fe48280e0293a04245ca5d919b2c94a48b408b55e858feae9618138aeda"},
+ {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dc3ad9ebc76eabe8b1d7c04d38be884b8f9d60c0cdc09b0aa4e3bcf746de0388"},
+ {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:32e16bdeffa7c4f46bf2152172ca511808b952701d13e7c18833c0b73cb5c23f"},
+ {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fb16ba563d59003028b678d2361a27f7e4ae0ab29c7a80690efa20d829c81fdb"},
+ {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:2277c36d2d6cdb7876c274547921a42425b6810d38354327dd65a8009acf870c"},
+ {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1cf75d32e8d250781940d07f7eece253f2fe9ecdb1dc7ba6e3833fa17b82fcbc"},
+ {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b3b31884dc8e9b21508bb76da80ebf7308fdb947a17affce815665d5c4d028"},
+ {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10122d8d8e30afb43bb1fe21a3619f62c3e2574bff2699cf8af8b0b6c5dc4a3"},
+ {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d88b96ff0fe8e91f6ef01ba50b0d71db5017fa4e3b1d99681cec89a85faf7bf7"},
+ {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:37aaec5a52e959892870a7c47cef80c53797c0db9149d458460f4f31e2fb250e"},
+ {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2ea752f2b0fe96eb6e2f3adbbf4d72aaa1272079b0dfa1145507bd6a5d537e6"},
+ {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b19a808d8799fda23504a5cd31d2f58e6f52f140380082b352f877017d6342b"},
+ {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c86e5e068ac8b19204419ed8ca90f9d25db20578f5881e337d203b314f4104"},
+ {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de19c4dc503c612847edf833c82e9f73cd79926a384af9d801dcf93f110cea4e"},
+ {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea09acd2fe3324174063d61ad620dec3bcf042b495515f27f638270a7d466e8b"},
+ {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cf27fd43472e07b57cf420eee1e814549203d56de00b5af8659cb99885472f1f"},
+ {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7ca22bd897537a0080521445d91a58886c8c04084a6a19e6c78c586e0cfa92a5"},
+ {file = "tokenizers-0.15.2.tar.gz", hash = "sha256:e6e9c6e019dd5484be5beafc775ae6c925f4c69a3487040ed09b45e13df2cb91"},
]
[package.dependencies]
@@ -3281,13 +3281,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
-version = "0.27.0.post1"
+version = "0.27.1"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
files = [
- {file = "uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f"},
- {file = "uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907"},
+ {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"},
+ {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"},
]
[package.dependencies]
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index 7683a8d066..fec5c72130 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
-version = "1.95.0"
+version = "1.95.1"
description = ""
authors = ["Hau Tran "]
readme = "README.md"
@@ -82,10 +82,10 @@ warn_untyped_fields = true
[tool.ruff]
line-length = 120
target-version = "py311"
-select = ["E", "F", "I"]
-[tool.ruff.per-file-ignores]
-"test_main.py" = ["F403"]
+[tool.ruff.lint]
+select = ["E", "F", "I"]
+per-file-ignores = { "test_main.py" = ["F403"] }
[tool.black]
line-length = 120
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index 0ac765a57d..fc63990518 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" => 122,
- "android.injected.version.name" => "1.95.0",
+ "android.injected.version.code" => 123,
+ "android.injected.version.name" => "1.95.1",
}
)
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/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index d26e87c968..d5a42ad485 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
- version_number: "1.95.0"
+ version_number: "1.95.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
diff --git a/mobile/lib/extensions/asyncvalue_extensions.dart b/mobile/lib/extensions/asyncvalue_extensions.dart
index c616835a81..af02ff13c2 100644
--- a/mobile/lib/extensions/asyncvalue_extensions.dart
+++ b/mobile/lib/extensions/asyncvalue_extensions.dart
@@ -30,7 +30,7 @@ extension LogOnError on AsyncValue {
}
if (hasError && !hasValue) {
- _asyncErrorLogger.severe("$error", error, stackTrace);
+ _asyncErrorLogger.severe('Could not load value', error, stackTrace);
return onError?.call(error, stackTrace) ??
ScaffoldErrorBody(errorMsg: error?.toString());
}
diff --git a/mobile/lib/extensions/response_extensions.dart b/mobile/lib/extensions/response_extensions.dart
new file mode 100644
index 0000000000..7fec41d07c
--- /dev/null
+++ b/mobile/lib/extensions/response_extensions.dart
@@ -0,0 +1,5 @@
+import 'package:http/http.dart';
+
+extension LoggerExtension on Response {
+ String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body";
+}
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 293867fb32..f20cf7ecc6 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -73,15 +73,14 @@ Future initApp() async {
FlutterError.onError = (details) {
FlutterError.presentError(details);
log.severe(
- 'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
- details,
+ 'FlutterError - Catch all',
+ "${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
details.stack,
);
};
PlatformDispatcher.instance.onError = (error, stack) {
- log.severe('PlatformDispatcher - Catch all error: $error', error, stack);
- debugPrint("PlatformDispatcher - Catch all error: $error $stack");
+ log.severe('PlatformDispatcher - Catch all', error, stack);
return true;
};
diff --git a/mobile/lib/mixins/error_logger.mixin.dart b/mobile/lib/mixins/error_logger.mixin.dart
index 38837a716f..9b2bc6f98e 100644
--- a/mobile/lib/mixins/error_logger.mixin.dart
+++ b/mobile/lib/mixins/error_logger.mixin.dart
@@ -10,13 +10,14 @@ mixin ErrorLoggerMixin {
/// Else, logs the error to the overrided logger and returns an AsyncError<>
AsyncFuture guardError(
Future Function() fn, {
+ required String errorMessage,
Level logLevel = Level.SEVERE,
}) async {
try {
final result = await fn();
return AsyncData(result);
} catch (error, stackTrace) {
- logger.log(logLevel, "$error", error, stackTrace);
+ logger.log(logLevel, errorMessage, error, stackTrace);
return AsyncError(error, stackTrace);
}
}
@@ -26,12 +27,13 @@ mixin ErrorLoggerMixin {
Future logError(
Future Function() fn, {
required T defaultValue,
+ required String errorMessage,
Level logLevel = Level.SEVERE,
}) async {
try {
return await fn();
} catch (error, stackTrace) {
- logger.log(logLevel, "$error", error, stackTrace);
+ logger.log(logLevel, errorMessage, error, stackTrace);
}
return defaultValue;
}
diff --git a/mobile/lib/modules/activities/services/activity.service.dart b/mobile/lib/modules/activities/services/activity.service.dart
index db35c17aee..cde98f73ae 100644
--- a/mobile/lib/modules/activities/services/activity.service.dart
+++ b/mobile/lib/modules/activities/services/activity.service.dart
@@ -24,6 +24,7 @@ class ActivityService with ErrorLoggerMixin {
return list != null ? list.map(Activity.fromDto).toList() : [];
},
defaultValue: [],
+ errorMessage: "Failed to get all activities for album $albumId",
);
}
@@ -35,6 +36,7 @@ class ActivityService with ErrorLoggerMixin {
return dto?.comments ?? 0;
},
defaultValue: 0,
+ errorMessage: "Failed to statistics for album $albumId",
);
}
@@ -45,6 +47,7 @@ class ActivityService with ErrorLoggerMixin {
return true;
},
defaultValue: false,
+ errorMessage: "Failed to delete activity",
);
}
@@ -54,21 +57,24 @@ class ActivityService with ErrorLoggerMixin {
String? assetId,
String? comment,
}) async {
- return guardError(() async {
- final dto = await _apiService.activityApi.createActivity(
- ActivityCreateDto(
- albumId: albumId,
- type: type == ActivityType.comment
- ? ReactionType.comment
- : ReactionType.like,
- assetId: assetId,
- comment: comment,
- ),
- );
- if (dto != null) {
- return Activity.fromDto(dto);
- }
- throw NoResponseDtoError();
- });
+ return guardError(
+ () async {
+ final dto = await _apiService.activityApi.createActivity(
+ ActivityCreateDto(
+ albumId: albumId,
+ type: type == ActivityType.comment
+ ? ReactionType.comment
+ : ReactionType.like,
+ assetId: assetId,
+ comment: comment,
+ ),
+ );
+ if (dto != null) {
+ return Activity.fromDto(dto);
+ }
+ throw NoResponseDtoError();
+ },
+ errorMessage: "Failed to create $type for album $albumId",
+ );
}
}
diff --git a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart
new file mode 100644
index 0000000000..224eb838e7
--- /dev/null
+++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart
@@ -0,0 +1,179 @@
+import 'dart:async';
+
+import 'package:chewie/chewie.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:video_player/video_player.dart';
+import 'package:immich_mobile/shared/models/store.dart' as store;
+import 'package:wakelock_plus/wakelock_plus.dart';
+
+/// Provides the initialized video player controller
+/// If the asset is local, use the local file
+/// Otherwise, use a video player with a URL
+ChewieController? useChewieController(
+ Asset asset, {
+ EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
+ bottom: 100,
+ ),
+ bool showOptions = true,
+ bool showControlsOnInitialize = false,
+ bool autoPlay = true,
+ bool autoInitialize = true,
+ bool allowFullScreen = false,
+ bool allowedScreenSleep = false,
+ bool showControls = true,
+ Widget? customControls,
+ Widget? placeholder,
+ Duration hideControlsTimer = const Duration(seconds: 1),
+ VoidCallback? onPlaying,
+ VoidCallback? onPaused,
+ VoidCallback? onVideoEnded,
+}) {
+ return use(
+ _ChewieControllerHook(
+ asset: asset,
+ placeholder: placeholder,
+ showOptions: showOptions,
+ controlsSafeAreaMinimum: controlsSafeAreaMinimum,
+ autoPlay: autoPlay,
+ allowFullScreen: allowFullScreen,
+ customControls: customControls,
+ hideControlsTimer: hideControlsTimer,
+ showControlsOnInitialize: showControlsOnInitialize,
+ showControls: showControls,
+ autoInitialize: autoInitialize,
+ allowedScreenSleep: allowedScreenSleep,
+ onPlaying: onPlaying,
+ onPaused: onPaused,
+ onVideoEnded: onVideoEnded,
+ ),
+ );
+}
+
+class _ChewieControllerHook extends Hook {
+ final Asset asset;
+ final EdgeInsets controlsSafeAreaMinimum;
+ final bool showOptions;
+ final bool showControlsOnInitialize;
+ final bool autoPlay;
+ final bool autoInitialize;
+ final bool allowFullScreen;
+ final bool allowedScreenSleep;
+ final bool showControls;
+ final Widget? customControls;
+ final Widget? placeholder;
+ final Duration hideControlsTimer;
+ final VoidCallback? onPlaying;
+ final VoidCallback? onPaused;
+ final VoidCallback? onVideoEnded;
+
+ const _ChewieControllerHook({
+ required this.asset,
+ this.controlsSafeAreaMinimum = const EdgeInsets.only(
+ bottom: 100,
+ ),
+ this.showOptions = true,
+ this.showControlsOnInitialize = false,
+ this.autoPlay = true,
+ this.autoInitialize = true,
+ this.allowFullScreen = false,
+ this.allowedScreenSleep = false,
+ this.showControls = true,
+ this.customControls,
+ this.placeholder,
+ this.hideControlsTimer = const Duration(seconds: 3),
+ this.onPlaying,
+ this.onPaused,
+ this.onVideoEnded,
+ });
+
+ @override
+ createState() => _ChewieControllerHookState();
+}
+
+class _ChewieControllerHookState
+ extends HookState {
+ ChewieController? chewieController;
+ VideoPlayerController? videoPlayerController;
+
+ @override
+ void initHook() async {
+ super.initHook();
+ unawaited(_initialize());
+ }
+
+ @override
+ void dispose() {
+ chewieController?.dispose();
+ videoPlayerController?.dispose();
+ super.dispose();
+ }
+
+ @override
+ ChewieController? build(BuildContext context) {
+ return chewieController;
+ }
+
+ /// Initializes the chewie controller and video player controller
+ Future _initialize() async {
+ if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
+ // Use a local file for the video player controller
+ final file = await hook.asset.local!.file;
+ if (file == null) {
+ throw Exception('No file found for the video');
+ }
+ videoPlayerController = VideoPlayerController.file(file);
+ } else {
+ // Use a network URL for the video player controller
+ final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
+ final String videoUrl = hook.asset.livePhotoVideoId != null
+ ? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
+ : '$serverEndpoint/asset/file/${hook.asset.remoteId}';
+
+ final url = Uri.parse(videoUrl);
+ final accessToken = store.Store.get(StoreKey.accessToken);
+
+ videoPlayerController = VideoPlayerController.networkUrl(
+ url,
+ httpHeaders: {"x-immich-user-token": accessToken},
+ );
+ }
+
+ videoPlayerController!.addListener(() {
+ final value = videoPlayerController!.value;
+ if (value.isPlaying) {
+ WakelockPlus.enable();
+ hook.onPlaying?.call();
+ } else if (!value.isPlaying) {
+ WakelockPlus.disable();
+ hook.onPaused?.call();
+ }
+
+ if (value.position == value.duration) {
+ WakelockPlus.disable();
+ hook.onVideoEnded?.call();
+ }
+ });
+
+ await videoPlayerController!.initialize();
+
+ setState(() {
+ chewieController = ChewieController(
+ videoPlayerController: videoPlayerController!,
+ controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
+ showOptions: hook.showOptions,
+ showControlsOnInitialize: hook.showControlsOnInitialize,
+ autoPlay: hook.autoPlay,
+ autoInitialize: hook.autoInitialize,
+ allowFullScreen: hook.allowFullScreen,
+ allowedScreenSleep: hook.allowedScreenSleep,
+ showControls: hook.showControls,
+ customControls: hook.customControls,
+ placeholder: hook.placeholder,
+ hideControlsTimer: hook.hideControlsTimer,
+ );
+ });
+ }
+}
diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart
index db527c6e23..54682fdeeb 100644
--- a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart
+++ b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
@@ -39,7 +40,8 @@ class ImageViewerService {
final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionReponse;
_log.severe(
- "Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}",
+ "Motion asset download failed",
+ failedResponse.toLoggerString(),
);
return false;
}
@@ -75,9 +77,7 @@ class ImageViewerService {
.downloadFileWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) {
- _log.severe(
- "Asset download failed with status - ${res.statusCode} and response - ${res.body}",
- );
+ _log.severe("Asset download failed", res.toLoggerString());
return false;
}
@@ -98,7 +98,7 @@ class ImageViewerService {
return entity != null;
}
} catch (error, stack) {
- _log.severe("Error saving file ${error.toString()}", error, stack);
+ _log.severe("Error saving downloaded asset", error, stack);
return false;
} finally {
// Clear temp files
diff --git a/mobile/lib/modules/asset_viewer/ui/description_input.dart b/mobile/lib/modules/asset_viewer/ui/description_input.dart
index c5972a822d..c5bae07cde 100644
--- a/mobile/lib/modules/asset_viewer/ui/description_input.dart
+++ b/mobile/lib/modules/asset_viewer/ui/description_input.dart
@@ -48,7 +48,7 @@ class DescriptionInput extends HookConsumerWidget {
);
} catch (error, stack) {
hasError.value = true;
- _log.severe("Error updating description $error", error, stack);
+ _log.severe("Error updating description", error, stack);
ImmichToast.show(
context: context,
msg: "description_input_submit_error".tr(),
diff --git a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart
index 781e84e458..bfc45b8a35 100644
--- a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart
+++ b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart
@@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerControls extends ConsumerStatefulWidget {
@@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState
children: [
if (_displayBufferingIndicator)
const Center(
- child: ImmichLoadingIndicator(),
+ child: DelayedLoadingIndicator(
+ fadeInDuration: Duration(milliseconds: 400),
+ ),
)
else
_buildHitArea(),
@@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState
@override
void dispose() {
_dispose();
+
super.dispose();
}
@@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
+ _latestValue = controller.value;
if (oldController != chewieController) {
_dispose();
@@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState
return GestureDetector(
onTap: () {
- if (_latestValue.isPlaying) {
- ref.read(showControlsProvider.notifier).show = false;
- } else {
+ if (!_latestValue.isPlaying) {
_playPause();
- ref.read(showControlsProvider.notifier).show = false;
}
+ ref.read(showControlsProvider.notifier).show = false;
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
@@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState
}
Future _initialize() async {
+ ref.read(showControlsProvider.notifier).show = false;
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
- controller.addListener(_updateState);
_latestValue = controller.value;
+ controller.addListener(_updateState);
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
@@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState
}
void _startHideTimer() {
- final hideControlsTimer = chewieController.hideControlsTimer.isNegative
- ? ChewieController.defaultHideControlsTimer
- : chewieController.hideControlsTimer;
+ final hideControlsTimer = chewieController.hideControlsTimer;
+ _hideTimer?.cancel();
_hideTimer = Timer(hideControlsTimer, () {
ref.read(showControlsProvider.notifier).show = false;
});
diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
index 73581b627d..48eb778c10 100644
--- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
+++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
@@ -699,6 +699,18 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
+ useEffect(
+ () {
+ if (ref.read(showControlsProvider)) {
+ SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+ } else {
+ SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
+ }
+ return null;
+ },
+ [],
+ );
+
ref.listen(showControlsProvider, (_, show) {
if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -795,7 +807,9 @@ class GalleryViewerPage extends HookConsumerWidget {
minScale: 1.0,
basePosition: Alignment.center,
child: VideoViewerPage(
- onPlaying: () => isPlayingVideo.value = true,
+ onPlaying: () {
+ isPlayingVideo.value = true;
+ },
onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false,
diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
index 72aa397f67..0967bf52a7 100644
--- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
+++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
@@ -1,23 +1,15 @@
-import 'dart:io';
-
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:chewie/chewie.dart';
-import 'package:immich_mobile/extensions/build_context_extensions.dart';
-import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
-import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/models/store.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
-import 'package:photo_manager/photo_manager.dart';
-import 'package:video_player/video_player.dart';
-import 'package:wakelock_plus/wakelock_plus.dart';
+import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
@RoutePage()
// ignore: must_be_immutable
-class VideoViewerPage extends HookConsumerWidget {
+class VideoViewerPage extends HookWidget {
final Asset asset;
final bool isMotionVideo;
final Widget? placeholder;
@@ -42,211 +34,49 @@ class VideoViewerPage extends HookConsumerWidget {
});
@override
- Widget build(BuildContext context, WidgetRef ref) {
- if (asset.isLocal && asset.livePhotoVideoId == null) {
- final AsyncValue videoFile = ref.watch(_fileFamily(asset.local!));
- return AnimatedSwitcher(
- duration: const Duration(milliseconds: 200),
- child: videoFile.when(
- data: (data) => VideoPlayer(
- file: data,
- isMotionVideo: false,
- onVideoEnded: () {},
- ),
- error: (error, stackTrace) => Icon(
- Icons.image_not_supported_outlined,
- color: context.primaryColor,
- ),
- loading: () => showDownloadingIndicator
- ? const Center(child: ImmichLoadingIndicator())
- : Container(),
- ),
- );
- }
- final downloadAssetStatus =
- ref.watch(imageViewerStateProvider).downloadAssetStatus;
- final String videoUrl = isMotionVideo
- ? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
- : '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
-
- return Stack(
- children: [
- VideoPlayer(
- url: videoUrl,
- accessToken: Store.get(StoreKey.accessToken),
- isMotionVideo: isMotionVideo,
- onVideoEnded: onVideoEnded,
- onPaused: onPaused,
- onPlaying: onPlaying,
- placeholder: placeholder,
- hideControlsTimer: hideControlsTimer,
- showControls: showControls,
- showDownloadingIndicator: showDownloadingIndicator,
- ),
- AnimatedOpacity(
- duration: const Duration(milliseconds: 400),
- opacity: (downloadAssetStatus == DownloadAssetStatus.loading &&
- showDownloadingIndicator)
- ? 1.0
- : 0.0,
- child: SizedBox(
- height: context.height,
- width: context.width,
- child: const Center(
- child: ImmichLoadingIndicator(),
- ),
- ),
- ),
- ],
- );
- }
-}
-
-final _fileFamily =
- FutureProvider.family((ref, entity) async {
- final file = await entity.file;
- if (file == null) {
- throw Exception();
- }
- return file;
-});
-
-class VideoPlayer extends StatefulWidget {
- final String? url;
- final String? accessToken;
- final File? file;
- final bool isMotionVideo;
- final VoidCallback? onVideoEnded;
- final Duration hideControlsTimer;
- final bool showControls;
-
- final Function()? onPlaying;
- final Function()? onPaused;
-
- /// The placeholder to show while the video is loading
- /// usually, a thumbnail of the video
- final Widget? placeholder;
-
- final bool showDownloadingIndicator;
-
- const VideoPlayer({
- super.key,
- this.url,
- this.accessToken,
- this.file,
- this.onVideoEnded,
- required this.isMotionVideo,
- this.onPlaying,
- this.onPaused,
- this.placeholder,
- this.hideControlsTimer = const Duration(
- seconds: 5,
- ),
- this.showControls = true,
- this.showDownloadingIndicator = true,
- });
-
- @override
- State createState() => _VideoPlayerState();
-}
-
-class _VideoPlayerState extends State {
- late VideoPlayerController videoPlayerController;
- ChewieController? chewieController;
-
- @override
- void initState() {
- super.initState();
- initializePlayer();
-
- videoPlayerController.addListener(() {
- if (videoPlayerController.value.isInitialized) {
- if (videoPlayerController.value.isPlaying) {
- WakelockPlus.enable();
- widget.onPlaying?.call();
- } else if (!videoPlayerController.value.isPlaying) {
- WakelockPlus.disable();
- widget.onPaused?.call();
- }
-
- if (videoPlayerController.value.position ==
- videoPlayerController.value.duration) {
- WakelockPlus.disable();
- widget.onVideoEnded?.call();
- }
- }
- });
- }
-
- Future initializePlayer() async {
- try {
- videoPlayerController = widget.file == null
- ? VideoPlayerController.networkUrl(
- Uri.parse(widget.url!),
- httpHeaders: {"x-immich-user-token": widget.accessToken ?? ""},
- )
- : VideoPlayerController.file(widget.file!);
-
- await videoPlayerController.initialize();
- _createChewieController();
- setState(() {});
- } catch (e) {
- debugPrint("ERROR initialize video player $e");
- }
- }
-
- _createChewieController() {
- chewieController = ChewieController(
+ Widget build(BuildContext context) {
+ final controller = useChewieController(
+ asset,
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
- showOptions: true,
- showControlsOnInitialize: false,
- videoPlayerController: videoPlayerController,
- autoPlay: true,
- autoInitialize: true,
- allowFullScreen: false,
- allowedScreenSleep: false,
- showControls: widget.showControls && !widget.isMotionVideo,
+ placeholder: placeholder,
+ showControls: showControls && !isMotionVideo,
+ hideControlsTimer: hideControlsTimer,
customControls: const VideoPlayerControls(),
- hideControlsTimer: widget.hideControlsTimer,
+ onPlaying: onPlaying,
+ onPaused: onPaused,
+ onVideoEnded: onVideoEnded,
+ );
+
+ // Loading
+ return PopScope(
+ child: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 400),
+ child: Builder(
+ builder: (context) {
+ if (controller == null) {
+ return Stack(
+ children: [
+ if (placeholder != null) placeholder!,
+ const DelayedLoadingIndicator(
+ fadeInDuration: Duration(milliseconds: 500),
+ ),
+ ],
+ );
+ }
+
+ final size = MediaQuery.of(context).size;
+ return SizedBox(
+ height: size.height,
+ width: size.width,
+ child: Chewie(
+ controller: controller,
+ ),
+ );
+ },
+ ),
+ ),
);
}
-
- @override
- void dispose() {
- super.dispose();
- videoPlayerController.pause();
- videoPlayerController.dispose();
- chewieController?.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- if (chewieController?.videoPlayerController.value.isInitialized == true) {
- return SizedBox(
- height: context.height,
- width: context.width,
- child: Chewie(
- controller: chewieController!,
- ),
- );
- } else {
- return SizedBox(
- height: context.height,
- width: context.width,
- child: Center(
- child: Stack(
- children: [
- if (widget.placeholder != null) widget.placeholder!,
- if (widget.showDownloadingIndicator)
- const Center(
- child: ImmichLoadingIndicator(),
- ),
- ],
- ),
- ),
- );
- }
- }
}
diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart
index a175a17de1..68c6bf9e66 100644
--- a/mobile/lib/modules/backup/providers/backup.provider.dart
+++ b/mobile/lib/modules/backup/providers/backup.provider.dart
@@ -245,7 +245,7 @@ class BackupNotifier extends StateNotifier {
} catch (e, stack) {
log.severe(
"Failed to get thumbnail for album ${album.name}",
- e.toString(),
+ e,
stack,
);
}
diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart
index 23dcd50533..e010024332 100644
--- a/mobile/lib/modules/login/providers/authentication.provider.dart
+++ b/mobile/lib/modules/login/providers/authentication.provider.dart
@@ -108,7 +108,7 @@ class AuthenticationNotifier extends StateNotifier {
.then((_) => log.info("Logout was successful for $userEmail"))
.onError(
(error, stackTrace) =>
- log.severe("Error logging out $userEmail", error, stackTrace),
+ log.severe("Logout failed for $userEmail", error, stackTrace),
);
await Future.wait([
@@ -129,8 +129,8 @@ class AuthenticationNotifier extends StateNotifier {
shouldChangePassword: false,
isAuthenticated: false,
);
- } catch (e) {
- log.severe("Error logging out $e");
+ } catch (e, stack) {
+ log.severe('Logout failed', e, stack);
}
}
diff --git a/mobile/lib/modules/login/services/oauth.service.dart b/mobile/lib/modules/login/services/oauth.service.dart
index 8f34c968eb..952c6fa8d6 100644
--- a/mobile/lib/modules/login/services/oauth.service.dart
+++ b/mobile/lib/modules/login/services/oauth.service.dart
@@ -36,7 +36,7 @@ class OAuthService {
),
);
} catch (e, stack) {
- log.severe("Error performing oAuthLogin: ${e.toString()}", e, stack);
+ log.severe("OAuth login failed", e, stack);
return null;
}
}
diff --git a/mobile/lib/modules/map/providers/map_state.provider.dart b/mobile/lib/modules/map/providers/map_state.provider.dart
index de6265c233..f1d1a4dde4 100644
--- a/mobile/lib/modules/map/providers/map_state.provider.dart
+++ b/mobile/lib/modules/map/providers/map_state.provider.dart
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
+import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -51,7 +52,8 @@ class MapStateNotifier extends _$MapStateNotifier {
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
);
_log.severe(
- "Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}",
+ "Cannot fetch map light style",
+ lightResponse.toLoggerString(),
);
return;
}
@@ -77,9 +79,7 @@ class MapStateNotifier extends _$MapStateNotifier {
state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
);
- _log.severe(
- "Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}",
- );
+ _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
return;
}
diff --git a/mobile/lib/modules/map/services/map.service.dart b/mobile/lib/modules/map/services/map.service.dart
index b3a904cbf1..0a5036056a 100644
--- a/mobile/lib/modules/map/services/map.service.dart
+++ b/mobile/lib/modules/map/services/map.service.dart
@@ -28,6 +28,7 @@ class MapSerivce with ErrorLoggerMixin {
return markers?.map(MapMarker.fromDto) ?? [];
},
defaultValue: [],
+ errorMessage: "Failed to get map markers",
);
}
}
diff --git a/mobile/lib/modules/map/utils/map_utils.dart b/mobile/lib/modules/map/utils/map_utils.dart
index 46af81ce1d..f6e8349f51 100644
--- a/mobile/lib/modules/map/utils/map_utils.dart
+++ b/mobile/lib/modules/map/utils/map_utils.dart
@@ -105,10 +105,8 @@ class MapUtils {
timeLimit: const Duration(seconds: 5),
);
return (currentUserLocation, null);
- } catch (error) {
- _log.severe(
- "Cannot get user's current location due to ${error.toString()}",
- );
+ } catch (error, stack) {
+ _log.severe("Cannot get user's current location", error, stack);
return (null, LocationPermission.unableToDetermine);
}
}
diff --git a/mobile/lib/modules/map/widgets/map_asset_grid.dart b/mobile/lib/modules/map/widgets/map_asset_grid.dart
index d1f187e258..ad90d36ed1 100644
--- a/mobile/lib/modules/map/widgets/map_asset_grid.dart
+++ b/mobile/lib/modules/map/widgets/map_asset_grid.dart
@@ -147,7 +147,7 @@ class MapAssetGrid extends HookConsumerWidget {
},
error: (error, stackTrace) {
log.warning(
- "Cannot get assets in the current map bounds $error",
+ "Cannot get assets in the current map bounds",
error,
stackTrace,
);
diff --git a/mobile/lib/modules/memories/services/memory.service.dart b/mobile/lib/modules/memories/services/memory.service.dart
index 8d2cd226a4..8ee203e6c9 100644
--- a/mobile/lib/modules/memories/services/memory.service.dart
+++ b/mobile/lib/modules/memories/services/memory.service.dart
@@ -47,7 +47,7 @@ class MemoryService {
return memories.isNotEmpty ? memories : null;
} catch (error, stack) {
- log.severe("Cannot get memories ${error.toString()}", error, stack);
+ log.severe("Cannot get memories", error, stack);
return null;
}
}
diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart
index af3cfc457e..5243e24a13 100644
--- a/mobile/lib/modules/memories/ui/memory_card.dart
+++ b/mobile/lib/modules/memories/ui/memory_card.dart
@@ -55,9 +55,9 @@ class MemoryCard extends StatelessWidget {
LayoutBuilder(
builder: (context, constraints) {
// Determine the fit using the aspect ratio
- BoxFit fit = BoxFit.fitWidth;
+ BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) {
- final aspectRatio = asset.height! / asset.width!;
+ final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio =
constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction
diff --git a/mobile/lib/modules/partner/services/partner.service.dart b/mobile/lib/modules/partner/services/partner.service.dart
index 32e500353b..d1e40076c7 100644
--- a/mobile/lib/modules/partner/services/partner.service.dart
+++ b/mobile/lib/modules/partner/services/partner.service.dart
@@ -40,7 +40,7 @@ class PartnerService {
return userDtos.map((u) => User.fromPartnerDto(u)).toList();
}
} catch (e) {
- _log.warning("failed to get partners for direction $direction:\n$e");
+ _log.warning("Failed to get partners for direction $direction", e);
}
return null;
}
@@ -51,7 +51,7 @@ class PartnerService {
partner.isPartnerSharedBy = false;
await _db.writeTxn(() => _db.users.put(partner));
} catch (e) {
- _log.warning("failed to remove partner ${partner.id}:\n$e");
+ _log.warning("Failed to remove partner ${partner.id}", e);
return false;
}
return true;
@@ -66,7 +66,7 @@ class PartnerService {
return true;
}
} catch (e) {
- _log.warning("failed to add partner ${partner.id}:\n$e");
+ _log.warning("Failed to add partner ${partner.id}", e);
}
return false;
}
@@ -81,7 +81,7 @@ class PartnerService {
return true;
}
} catch (e) {
- _log.warning("failed to update partner ${partner.id}:\n$e");
+ _log.warning("Failed to update partner ${partner.id}", e);
}
return false;
}
diff --git a/mobile/lib/modules/shared_link/services/shared_link.service.dart b/mobile/lib/modules/shared_link/services/shared_link.service.dart
index 3ea1d411b2..62f431580c 100644
--- a/mobile/lib/modules/shared_link/services/shared_link.service.dart
+++ b/mobile/lib/modules/shared_link/services/shared_link.service.dart
@@ -22,7 +22,7 @@ class SharedLinkService {
? AsyncData(list.map(SharedLink.fromDto).toList())
: const AsyncData([]);
} catch (e, stack) {
- _log.severe("failed to fetch shared links - $e");
+ _log.severe("Failed to fetch shared links", e, stack);
return AsyncError(e, stack);
}
}
@@ -31,7 +31,7 @@ class SharedLinkService {
try {
return await _apiService.sharedLinkApi.removeSharedLink(id);
} catch (e) {
- _log.severe("failed to delete shared link id - $id with error - $e");
+ _log.severe("Failed to delete shared link id - $id", e);
}
}
@@ -81,7 +81,7 @@ class SharedLinkService {
}
}
} catch (e) {
- _log.severe("failed to create shared link with error - $e");
+ _log.severe("Failed to create shared link", e);
}
return null;
}
@@ -113,7 +113,7 @@ class SharedLinkService {
return SharedLink.fromDto(responseDto);
}
} catch (e) {
- _log.severe("failed to update shared link id - $id with error - $e");
+ _log.severe("Failed to update shared link id - $id", e);
}
return null;
}
diff --git a/mobile/lib/modules/trash/providers/trashed_asset.provider.dart b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart
index 177e7d2d4c..165d1c0f74 100644
--- a/mobile/lib/modules/trash/providers/trashed_asset.provider.dart
+++ b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart
@@ -44,7 +44,7 @@ class TrashNotifier extends StateNotifier {
.read(syncServiceProvider)
.handleRemoteAssetRemoval(idsToRemove.cast().toList());
} catch (error, stack) {
- _log.severe("Cannot empty trash ${error.toString()}", error, stack);
+ _log.severe("Cannot empty trash", error, stack);
}
}
@@ -70,7 +70,7 @@ class TrashNotifier extends StateNotifier {
return isRemoved;
} catch (error, stack) {
- _log.severe("Cannot empty trash ${error.toString()}", error, stack);
+ _log.severe("Cannot remove assets", error, stack);
}
return false;
}
@@ -93,7 +93,7 @@ class TrashNotifier extends StateNotifier {
return true;
}
} catch (error, stack) {
- _log.severe("Cannot restore trash ${error.toString()}", error, stack);
+ _log.severe("Cannot restore assets", error, stack);
}
return false;
}
@@ -123,7 +123,7 @@ class TrashNotifier extends StateNotifier {
await _db.assets.putAll(updatedAssets);
});
} catch (error, stack) {
- _log.severe("Cannot restore trash ${error.toString()}", error, stack);
+ _log.severe("Cannot restore trash", error, stack);
}
}
}
diff --git a/mobile/lib/modules/trash/services/trash.service.dart b/mobile/lib/modules/trash/services/trash.service.dart
index 9a9ff5d0b6..96b07ca20f 100644
--- a/mobile/lib/modules/trash/services/trash.service.dart
+++ b/mobile/lib/modules/trash/services/trash.service.dart
@@ -25,7 +25,7 @@ class TrashService {
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds));
return true;
} catch (error, stack) {
- _log.severe("Cannot restore assets ${error.toString()}", error, stack);
+ _log.severe("Cannot restore assets", error, stack);
return false;
}
}
@@ -34,7 +34,7 @@ class TrashService {
try {
await _apiService.trashApi.emptyTrash();
} catch (error, stack) {
- _log.severe("Cannot empty trash ${error.toString()}", error, stack);
+ _log.severe("Cannot empty trash", error, stack);
}
}
@@ -42,7 +42,7 @@ class TrashService {
try {
await _apiService.trashApi.restoreTrash();
} catch (error, stack) {
- _log.severe("Cannot restore trash ${error.toString()}", error, stack);
+ _log.severe("Cannot restore trash", error, stack);
}
}
}
diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart
index 6aee9271f4..fe212c4ca9 100644
--- a/mobile/lib/routing/auth_guard.dart
+++ b/mobile/lib/routing/auth_guard.dart
@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
-import 'package:flutter/foundation.dart';
import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -16,28 +16,31 @@ class AuthGuard extends AutoRouteGuard {
resolver.next(true);
try {
- var res = await _apiService.authenticationApi.validateAccessToken();
+ // Look in the store for an access token
+ Store.get(StoreKey.accessToken);
+
+ // Validate the access token with the server
+ final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login
- _log.fine("User token is invalid. Redirecting to login");
+ _log.fine('User token is invalid. Redirecting to login');
router.replaceAll([const LoginRoute()]);
}
+ } on StoreKeyNotFoundException catch (_) {
+ // If there is no access token, take us to the login page
+ _log.warning('No access token in the store.');
+ router.replaceAll([const LoginRoute()]);
+ return;
} on ApiException catch (e) {
- if (e.code == HttpStatus.badRequest &&
- e.innerException is SocketException) {
- // offline?
- _log.fine(
- "Unable to validate user token. User may be offline and offline browsing is allowed.",
- );
- } else {
- debugPrint("Error [onNavigation] ${e.toString()}");
+ // On an unauthorized request, take us to the login page
+ if (e.code == HttpStatus.unauthorized) {
+ _log.warning("Unauthorized access token.");
router.replaceAll([const LoginRoute()]);
return;
}
} catch (e) {
- debugPrint("Error [onNavigation] ${e.toString()}");
- router.replaceAll([const LoginRoute()]);
- return;
+ // Otherwise, this is not fatal, but we still log the warning
+ _log.warning('Error validating access token from server: $e');
}
}
}
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index f6968dafe5..16ac5efb0e 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo {
void Function()? onPaused,
Widget? placeholder,
bool showControls = true,
- Duration hideControlsTimer = const Duration(seconds: 5),
+ Duration hideControlsTimer = const Duration(milliseconds: 1500),
bool showDownloadingIndicator = true,
List? children,
}) : super(
@@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused,
this.placeholder,
this.showControls = true,
- this.hideControlsTimer = const Duration(seconds: 5),
+ this.hideControlsTimer = const Duration(milliseconds: 1500),
this.showDownloadingIndicator = true,
});
diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart
index f2183602c1..3c3c4df82f 100644
--- a/mobile/lib/shared/models/asset.dart
+++ b/mobile/lib/shared/models/asset.dart
@@ -175,6 +175,11 @@ class Asset {
int? stackCount;
+ /// Aspect ratio of the asset
+ @ignore
+ double? get aspectRatio =>
+ width == null || height == null ? 0 : width! / height!;
+
/// `true` if this [Asset] is present on the device
@ignore
bool get isLocal => localId != null;
diff --git a/mobile/lib/shared/models/logger_message.model.dart b/mobile/lib/shared/models/logger_message.model.dart
index cb1d45a580..f657257eab 100644
--- a/mobile/lib/shared/models/logger_message.model.dart
+++ b/mobile/lib/shared/models/logger_message.model.dart
@@ -9,6 +9,7 @@ part 'logger_message.model.g.dart';
class LoggerMessage {
Id id = Isar.autoIncrement;
String message;
+ String? details;
@Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO;
DateTime createdAt;
@@ -17,6 +18,7 @@ class LoggerMessage {
LoggerMessage({
required this.message,
+ required this.details,
required this.level,
required this.createdAt,
required this.context1,
diff --git a/mobile/lib/shared/models/logger_message.model.g.dart b/mobile/lib/shared/models/logger_message.model.g.dart
index a6b960eece..76c823704c 100644
Binary files a/mobile/lib/shared/models/logger_message.model.g.dart and b/mobile/lib/shared/models/logger_message.model.g.dart differ
diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart
index 64a0f28ab7..3086ab9246 100644
--- a/mobile/lib/shared/services/asset.service.dart
+++ b/mobile/lib/shared/services/asset.service.dart
@@ -90,7 +90,7 @@ class AssetService {
return allAssets;
} catch (error, stack) {
log.severe(
- 'Error while getting remote assets: ${error.toString()}',
+ 'Error while getting remote assets',
error,
stack,
);
@@ -117,7 +117,7 @@ class AssetService {
);
return true;
} catch (error, stack) {
- log.severe("Error deleteAssets ${error.toString()}", error, stack);
+ log.severe("Error while deleting assets", error, stack);
}
return false;
}
diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart
index b66177e570..967ab2d5f2 100644
--- a/mobile/lib/shared/services/immich_logger.service.dart
+++ b/mobile/lib/shared/services/immich_logger.service.dart
@@ -12,7 +12,7 @@ import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to the database and onto console, using `debugPrint` method.
///
-/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
+/// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
/// in the class.
///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
@@ -58,6 +58,7 @@ class ImmichLogger {
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
final lm = LoggerMessage(
message: record.message,
+ details: record.error?.toString(),
level: record.level.toLogLevel(),
createdAt: record.time,
context1: record.loggerName,
diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart
index d7daa51b86..be7c0c168d 100644
--- a/mobile/lib/shared/services/share.service.dart
+++ b/mobile/lib/shared/services/share.service.dart
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
@@ -41,7 +42,8 @@ class ShareService {
if (res.statusCode != 200) {
_log.severe(
- "Asset download failed with status - ${res.statusCode} and response - ${res.body}",
+ "Asset download for ${asset.fileName} failed",
+ res.toLoggerString(),
);
continue;
}
@@ -68,7 +70,7 @@ class ShareService {
);
return true;
} catch (error) {
- _log.severe("Share failed with error $error");
+ _log.severe("Share failed", error);
}
return false;
}
diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart
index d039b34094..a441091d37 100644
--- a/mobile/lib/shared/services/sync.service.dart
+++ b/mobile/lib/shared/services/sync.service.dart
@@ -140,7 +140,7 @@ class SyncService {
try {
await _db.writeTxn(() => a.put(_db));
} on IsarError catch (e) {
- _log.severe("Failed to put new asset into db: $e");
+ _log.severe("Failed to put new asset into db", e);
return false;
}
return true;
@@ -173,7 +173,7 @@ class SyncService {
}
return false;
} on IsarError catch (e) {
- _log.severe("Failed to sync remote assets to db: $e");
+ _log.severe("Failed to sync remote assets to db", e);
}
return null;
}
@@ -232,7 +232,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await upsertAssetsWithExif(toAdd + toUpdate);
} on IsarError catch (e) {
- _log.severe("Failed to sync remote assets to db: $e");
+ _log.severe("Failed to sync remote assets to db", e);
}
await _updateUserAssetsETag(user, now);
return true;
@@ -364,7 +364,7 @@ class SyncService {
});
_log.info("Synced changes of remote album ${album.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to sync remote album to database $e");
+ _log.severe("Failed to sync remote album to database", e);
}
if (album.shared || dto.shared) {
@@ -441,7 +441,7 @@ class SyncService {
assert(ok);
_log.info("Removed local album $album from DB");
} catch (e) {
- _log.severe("Failed to remove local album $album from DB");
+ _log.severe("Failed to remove local album $album from DB", e);
}
}
@@ -577,7 +577,7 @@ class SyncService {
});
_log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to update synced album ${ape.name} in DB: $e");
+ _log.severe("Failed to update synced album ${ape.name} in DB", e);
}
return true;
@@ -623,7 +623,7 @@ class SyncService {
});
_log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to fast sync local album ${ape.name} to DB: $e");
+ _log.severe("Failed to fast sync local album ${ape.name} to DB", e);
return false;
}
@@ -656,7 +656,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e) {
- _log.severe("Failed to add new local album ${ape.name} to DB: $e");
+ _log.severe("Failed to add new local album ${ape.name} to DB", e);
}
}
@@ -706,9 +706,7 @@ class SyncService {
});
_log.info("Upserted ${assets.length} assets into the DB");
} on IsarError catch (e) {
- _log.severe(
- "Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
- );
+ _log.severe("Failed to upsert ${assets.length} assets into the DB", e);
// give details on the errors
assets.sort(Asset.compareByOwnerChecksum);
final inDb = await _db.assets.getAllByOwnerIdChecksum(
@@ -776,7 +774,7 @@ class SyncService {
});
return true;
} catch (e) {
- _log.severe("Failed to remove all local albums and assets: $e");
+ _log.severe("Failed to remove all local albums and assets", e);
return false;
}
}
diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart
index 4d398c3a88..ae65ed31db 100644
--- a/mobile/lib/shared/services/user.service.dart
+++ b/mobile/lib/shared/services/user.service.dart
@@ -42,7 +42,7 @@ class UserService {
final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromUserDto).toList();
} catch (e) {
- _log.warning("Failed get all users:\n$e");
+ _log.warning("Failed get all users", e);
return null;
}
}
@@ -65,7 +65,7 @@ class UserService {
),
);
} catch (e) {
- _log.warning("Failed to upload profile image:\n$e");
+ _log.warning("Failed to upload profile image", e);
return null;
}
}
diff --git a/mobile/lib/shared/ui/delayed_loading_indicator.dart b/mobile/lib/shared/ui/delayed_loading_indicator.dart
new file mode 100644
index 0000000000..b4d9f4c806
--- /dev/null
+++ b/mobile/lib/shared/ui/delayed_loading_indicator.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class DelayedLoadingIndicator extends StatelessWidget {
+ /// The delay to avoid showing the loading indicator
+ final Duration delay;
+
+ /// Defaults to using the [ImmichLoadingIndicator]
+ final Widget? child;
+
+ /// An optional fade in duration to animate the loading
+ final Duration? fadeInDuration;
+
+ const DelayedLoadingIndicator({
+ super.key,
+ this.delay = const Duration(seconds: 3),
+ this.child,
+ this.fadeInDuration,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedSwitcher(
+ duration: fadeInDuration ?? Duration.zero,
+ child: FutureBuilder(
+ future: Future.delayed(delay),
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.done) {
+ return child ??
+ const ImmichLoadingIndicator(
+ key: ValueKey('loading'),
+ );
+ }
+
+ return Container(key: const ValueKey('hiding'));
+ },
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/shared/views/app_log_detail_page.dart b/mobile/lib/shared/views/app_log_detail_page.dart
index 126f46c8ff..6b99d7f0af 100644
--- a/mobile/lib/shared/views/app_log_detail_page.dart
+++ b/mobile/lib/shared/views/app_log_detail_page.dart
@@ -15,7 +15,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
var isDarkTheme = context.isDarkTheme;
- buildStackMessage(String stackTrace) {
+ buildTextWithCopyButton(String header, String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
@@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
- "STACK TRACES",
+ header,
style: TextStyle(
fontSize: 12.0,
color: context.primaryColor,
@@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget {
),
IconButton(
onPressed: () {
- Clipboard.setData(ClipboardData(text: stackTrace))
- .then((_) {
+ Clipboard.setData(ClipboardData(text: text)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SelectableText(
- stackTrace,
- style: const TextStyle(
- fontSize: 12.0,
- fontWeight: FontWeight.bold,
- fontFamily: "Inconsolata",
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
-
- buildLogMessage(String message) {
- return Padding(
- padding: const EdgeInsets.all(8.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.only(bottom: 8.0),
- child: Text(
- "MESSAGE",
- style: TextStyle(
- fontSize: 12.0,
- color: context.primaryColor,
- fontWeight: FontWeight.bold,
- ),
- ),
- ),
- IconButton(
- onPressed: () {
- Clipboard.setData(ClipboardData(text: message)).then((_) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- "Copied to clipboard",
- style: context.textTheme.bodyLarge?.copyWith(
- color: context.primaryColor,
- ),
- ),
- ),
- );
- });
- },
- icon: Icon(
- Icons.copy,
- size: 16.0,
- color: context.primaryColor,
- ),
- ),
- ],
- ),
- Container(
- decoration: BoxDecoration(
- color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
- borderRadius: BorderRadius.circular(15.0),
- ),
- child: Padding(
- padding: const EdgeInsets.all(8.0),
- child: SelectableText(
- message,
+ text,
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
@@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget {
body: SafeArea(
child: ListView(
children: [
- buildLogMessage(logMessage.message),
+ buildTextWithCopyButton("MESSAGE", logMessage.message),
+ if (logMessage.details != null)
+ buildTextWithCopyButton("DETAILS", logMessage.details.toString()),
if (logMessage.context1 != null)
buildLogContext1(logMessage.context1.toString()),
if (logMessage.context2 != null)
- buildStackMessage(logMessage.context2.toString()),
+ buildTextWithCopyButton(
+ "STACK TRACE",
+ logMessage.context2.toString(),
+ ),
],
),
),
diff --git a/mobile/lib/shared/views/app_log_page.dart b/mobile/lib/shared/views/app_log_page.dart
index a0c4553f98..993b25c7cf 100644
--- a/mobile/lib/shared/views/app_log_page.dart
+++ b/mobile/lib/shared/views/app_log_page.dart
@@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
- title: Text(
- "Logs - ${logMessages.value.length}",
- style: const TextStyle(
+ title: const Text(
+ "Logs",
+ style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
@@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget {
dense: true,
tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10,
- title: Text.rich(
- TextSpan(
- children: [
- TextSpan(
- text: "#$index ",
- style: TextStyle(
- color: isDarkTheme ? Colors.white70 : Colors.grey[600],
- fontSize: 14.0,
- fontWeight: FontWeight.bold,
- ),
- ),
- TextSpan(
- text: truncateLogMessage(logMessage.message, 4),
- style: const TextStyle(
- fontSize: 14.0,
- ),
- ),
- ],
+ title: Text(
+ truncateLogMessage(logMessage.message, 4),
+ style: const TextStyle(
+ fontSize: 14.0,
+ fontFamily: "Inconsolata",
),
- style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
),
subtitle: Text(
- "[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
+ "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}",
style: TextStyle(
fontSize: 12.0,
color: Colors.grey[600],
diff --git a/mobile/lib/shared/views/immich_loading_overlay.dart b/mobile/lib/shared/views/immich_loading_overlay.dart
index 85f0123ed9..c600d2a724 100644
--- a/mobile/lib/shared/views/immich_loading_overlay.dart
+++ b/mobile/lib/shared/views/immich_loading_overlay.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square(
@@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox(
decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
- child: const Center(child: ImmichLoadingIndicator()),
+ child: const Center(
+ child: DelayedLoadingIndicator(
+ delay: Duration(seconds: 1),
+ fadeInDuration: Duration(milliseconds: 400),
+ ),
+ ),
),
),
);
@@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook> {
class _LoadingOverlayState
extends HookState, _LoadingOverlay> {
- late final _isProcessing = ValueNotifier(false)..addListener(_listener);
- OverlayEntry? overlayEntry;
+ late final _isLoading = ValueNotifier(false)..addListener(_listener);
+ OverlayEntry? _loadingOverlay;
void _listener() {
setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (_isProcessing.value) {
- overlayEntry?.remove();
- overlayEntry = _loadingEntry;
+ if (_isLoading.value) {
+ _loadingOverlay?.remove();
+ _loadingOverlay = _loadingEntry;
Overlay.of(context).insert(_loadingEntry);
} else {
- overlayEntry?.remove();
- overlayEntry = null;
+ _loadingOverlay?.remove();
+ _loadingOverlay = null;
}
});
});
@@ -47,17 +52,17 @@ class _LoadingOverlayState
@override
ValueNotifier build(BuildContext context) {
- return _isProcessing;
+ return _isLoading;
}
@override
void dispose() {
- _isProcessing.dispose();
+ _isLoading.dispose();
super.dispose();
}
@override
- Object? get debugValue => _isProcessing.value;
+ Object? get debugValue => _isLoading.value;
@override
String get debugLabel => 'useProcessingOverlay<>';
diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart
index 8dddb60aaa..3c0d65bde9 100644
--- a/mobile/lib/shared/views/splash_screen.dart
+++ b/mobile/lib/shared/views/splash_screen.dart
@@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget {
deviceIsOffline = true;
log.fine("Device seems to be offline upon launch");
} else {
- log.severe(e);
+ log.severe("Failed to resolve endpoint", e);
}
} catch (e) {
- log.severe(e);
+ log.severe("Failed to resolve endpoint", e);
}
try {
@@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget {
ref.read(authenticationProvider.notifier).logout();
log.severe(
- 'Cannot set success login info: $error',
+ 'Cannot set success login info',
error,
stackTrace,
);
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index 0679a1749d..ea413b4870 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -108,6 +108,7 @@ doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md
doc/PersonWithFacesResponseDto.md
+doc/PlacesResponseDto.md
doc/QueueStatusDto.md
doc/ReactionLevel.md
doc/ReactionType.md
@@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart
lib/model/person_with_faces_response_dto.dart
+lib/model/places_response_dto.dart
lib/model/queue_status_dto.dart
lib/model/reaction_level.dart
lib/model/reaction_type.dart
@@ -485,6 +487,7 @@ test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart
test/person_with_faces_response_dto_test.dart
+test/places_response_dto_test.dart
test/queue_status_dto_test.dart
test/reaction_level_test.dart
test/reaction_type_test.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index a2b155bcd9..41e65ee8b3 100644
Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ
diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md
index 5691965bc7..93b758a595 100644
Binary files a/mobile/openapi/doc/AssetApi.md and b/mobile/openapi/doc/AssetApi.md differ
diff --git a/mobile/openapi/doc/MetadataSearchDto.md b/mobile/openapi/doc/MetadataSearchDto.md
index bfbf81749e..d1d098fb0e 100644
Binary files a/mobile/openapi/doc/MetadataSearchDto.md and b/mobile/openapi/doc/MetadataSearchDto.md differ
diff --git a/mobile/openapi/doc/PeopleResponseDto.md b/mobile/openapi/doc/PeopleResponseDto.md
index 2f87f19993..78f9b2207c 100644
Binary files a/mobile/openapi/doc/PeopleResponseDto.md and b/mobile/openapi/doc/PeopleResponseDto.md differ
diff --git a/mobile/openapi/doc/PlacesResponseDto.md b/mobile/openapi/doc/PlacesResponseDto.md
new file mode 100644
index 0000000000..a4bf36493c
Binary files /dev/null and b/mobile/openapi/doc/PlacesResponseDto.md differ
diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md
index f975e94484..f63488222b 100644
Binary files a/mobile/openapi/doc/SearchApi.md and b/mobile/openapi/doc/SearchApi.md differ
diff --git a/mobile/openapi/doc/SmartSearchDto.md b/mobile/openapi/doc/SmartSearchDto.md
index 5d34143df2..d4ec1a70f6 100644
Binary files a/mobile/openapi/doc/SmartSearchDto.md and b/mobile/openapi/doc/SmartSearchDto.md differ
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 72a6567648..56bd907e0a 100644
Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ
diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart
index 062ca4a50b..3a0bc56bb6 100644
Binary files a/mobile/openapi/lib/api/search_api.dart and b/mobile/openapi/lib/api/search_api.dart differ
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 2df5e67119..24cffb7cff 100644
Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ
diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart
index 47756cd527..86a2856e66 100644
Binary files a/mobile/openapi/lib/model/metadata_search_dto.dart and b/mobile/openapi/lib/model/metadata_search_dto.dart differ
diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart
index 80abedfc72..02a82cadf1 100644
Binary files a/mobile/openapi/lib/model/people_response_dto.dart and b/mobile/openapi/lib/model/people_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart
new file mode 100644
index 0000000000..a2d8378883
Binary files /dev/null and b/mobile/openapi/lib/model/places_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart
index b82a3345fb..664850db82 100644
Binary files a/mobile/openapi/lib/model/smart_search_dto.dart and b/mobile/openapi/lib/model/smart_search_dto.dart differ
diff --git a/mobile/openapi/test/metadata_search_dto_test.dart b/mobile/openapi/test/metadata_search_dto_test.dart
index f1635de4e0..f817b7da74 100644
Binary files a/mobile/openapi/test/metadata_search_dto_test.dart and b/mobile/openapi/test/metadata_search_dto_test.dart differ
diff --git a/mobile/openapi/test/people_response_dto_test.dart b/mobile/openapi/test/people_response_dto_test.dart
index ad669eeced..94db6eb86b 100644
Binary files a/mobile/openapi/test/people_response_dto_test.dart and b/mobile/openapi/test/people_response_dto_test.dart differ
diff --git a/mobile/openapi/test/places_response_dto_test.dart b/mobile/openapi/test/places_response_dto_test.dart
new file mode 100644
index 0000000000..5a320fce64
Binary files /dev/null and b/mobile/openapi/test/places_response_dto_test.dart differ
diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart
index 14169e461d..aa4a94847b 100644
Binary files a/mobile/openapi/test/search_api_test.dart and b/mobile/openapi/test/search_api_test.dart differ
diff --git a/mobile/openapi/test/smart_search_dto_test.dart b/mobile/openapi/test/smart_search_dto_test.dart
index 858c7769c8..4db3ac0808 100644
Binary files a/mobile/openapi/test/smart_search_dto_test.dart and b/mobile/openapi/test/smart_search_dto_test.dart differ
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 7608a3ab6c..9e379d4653 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -413,10 +413,10 @@ packages:
dependency: transitive
description:
name: file
- sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
+ sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev"
source: hosted
- version: "6.1.4"
+ version: "7.0.0"
file_selector_linux:
dependency: transitive
description:
@@ -569,10 +569,10 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
- sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84"
+ sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
url: "https://pub.dev"
source: hosted
- version: "2.1.1"
+ version: "3.0.0"
flutter_web_auth:
dependency: "direct main"
description:
@@ -619,10 +619,10 @@ packages:
dependency: "direct main"
description:
name: geolocator
- sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02
+ sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd"
url: "https://pub.dev"
source: hosted
- version: "10.1.0"
+ version: "11.0.0"
geolocator_android:
dependency: transitive
description:
@@ -651,10 +651,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
- sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58"
+ sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev"
source: hosted
- version: "2.2.0"
+ version: "3.0.0"
geolocator_windows:
dependency: transitive
description:
@@ -860,6 +860,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.1"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.0.0"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
lints:
dependency: transitive
description:
@@ -907,18 +931,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
+ sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
- version: "0.12.16"
+ version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
+ sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
- version: "0.5.0"
+ version: "0.8.0"
meta:
dependency: "direct overridden"
description:
@@ -1002,10 +1026,10 @@ packages:
dependency: "direct main"
description:
name: path
- sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
+ sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
- version: "1.8.3"
+ version: "1.9.0"
path_provider:
dependency: "direct main"
description:
@@ -1138,10 +1162,10 @@ packages:
dependency: transitive
description:
name: platform
- sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
+ sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
url: "https://pub.dev"
source: hosted
- version: "3.1.2"
+ version: "3.1.4"
plugin_platform_interface:
dependency: transitive
description:
@@ -1170,10 +1194,10 @@ packages:
dependency: transitive
description:
name: process
- sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09"
+ sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
url: "https://pub.dev"
source: hosted
- version: "4.2.4"
+ version: "5.0.2"
provider:
dependency: transitive
description:
@@ -1298,10 +1322,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_linux
- sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1"
+ sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
url: "https://pub.dev"
source: hosted
- version: "2.3.0"
+ version: "2.3.2"
shared_preferences_platform_interface:
dependency: transitive
description:
@@ -1322,10 +1346,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_windows
- sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d
+ sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
url: "https://pub.dev"
source: hosted
- version: "2.3.0"
+ version: "2.3.2"
shelf:
dependency: transitive
description:
@@ -1639,10 +1663,10 @@ packages:
dependency: transitive
description:
name: vm_service
- sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583
+ sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
url: "https://pub.dev"
source: hosted
- version: "11.10.0"
+ version: "13.0.0"
wakelock_plus:
dependency: "direct main"
description:
@@ -1687,10 +1711,10 @@ packages:
dependency: transitive
description:
name: webdriver
- sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49"
+ sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
url: "https://pub.dev"
source: hosted
- version: "3.0.2"
+ version: "3.0.3"
win32:
dependency: transitive
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 2e1e0dd07e..50d170904f 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: 1.95.0+122
+version: 1.95.1+123
isar_version: &isar_version 3.1.0+1
environment:
@@ -32,8 +32,8 @@ dependencies:
git:
url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
- geolocator: ^10.1.0 # used to move to current location in map view
- flutter_udid: ^2.1.1
+ geolocator: ^11.0.0 # used to move to current location in map view
+ flutter_udid: ^3.0.0
package_info_plus: ^5.0.1
url_launcher: ^6.2.4
http: 0.13.5
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 6870e140ca..8fec893270 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -2463,6 +2463,7 @@
"required": false,
"in": "query",
"schema": {
+ "default": false,
"type": "boolean"
}
},
@@ -4690,6 +4691,50 @@
]
}
},
+ "/search/places": {
+ "get": {
+ "operationId": "searchPlaces",
+ "parameters": [
+ {
+ "name": "name",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/PlacesResponseDto"
+ },
+ "type": "array"
+ }
+ }
+ },
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ },
+ {
+ "cookie": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "tags": [
+ "Search"
+ ]
+ }
+ },
"/search/smart": {
"post": {
"operationId": "searchSmart",
@@ -6413,7 +6458,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
- "version": "1.95.0",
+ "version": "1.95.1",
"contact": {}
},
"tags": [],
@@ -8429,6 +8474,7 @@
"type": "string"
},
"withArchived": {
+ "default": false,
"type": "boolean"
},
"withDeleted": {
@@ -8591,6 +8637,9 @@
},
"PeopleResponseDto": {
"properties": {
+ "hidden": {
+ "type": "integer"
+ },
"people": {
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
@@ -8602,6 +8651,7 @@
}
},
"required": [
+ "hidden",
"people",
"total"
],
@@ -8750,6 +8800,31 @@
],
"type": "object"
},
+ "PlacesResponseDto": {
+ "properties": {
+ "admin1name": {
+ "type": "string"
+ },
+ "admin2name": {
+ "type": "string"
+ },
+ "latitude": {
+ "type": "number"
+ },
+ "longitude": {
+ "type": "number"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "latitude",
+ "longitude",
+ "name"
+ ],
+ "type": "object"
+ },
"QueueStatusDto": {
"properties": {
"isActive": {
@@ -9435,6 +9510,9 @@
"isMotion": {
"type": "boolean"
},
+ "isNotInAlbum": {
+ "type": "boolean"
+ },
"isOffline": {
"type": "boolean"
},
@@ -9497,6 +9575,7 @@
"type": "string"
},
"withArchived": {
+ "default": false,
"type": "boolean"
},
"withDeleted": {
diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts
index 622820c752..ad36bb4932 100644
--- a/open-api/typescript-sdk/axios-client/api.ts
+++ b/open-api/typescript-sdk/axios-client/api.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -2801,6 +2801,12 @@ export type PathType = typeof PathType[keyof typeof PathType];
* @interface PeopleResponseDto
*/
export interface PeopleResponseDto {
+ /**
+ *
+ * @type {number}
+ * @memberof PeopleResponseDto
+ */
+ 'hidden': number;
/**
*
* @type {Array}
@@ -2988,6 +2994,43 @@ export interface PersonWithFacesResponseDto {
*/
'thumbnailPath': string;
}
+/**
+ *
+ * @export
+ * @interface PlacesResponseDto
+ */
+export interface PlacesResponseDto {
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'admin1name'?: string;
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'admin2name'?: string;
+ /**
+ *
+ * @type {number}
+ * @memberof PlacesResponseDto
+ */
+ 'latitude': number;
+ /**
+ *
+ * @type {number}
+ * @memberof PlacesResponseDto
+ */
+ 'longitude': number;
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'name': string;
+}
/**
*
* @export
@@ -3880,6 +3923,12 @@ export interface SmartSearchDto {
* @memberof SmartSearchDto
*/
'isMotion'?: boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof SmartSearchDto
+ */
+ 'isNotInAlbum'?: boolean;
/**
*
* @type {boolean}
@@ -15435,6 +15484,51 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ *
+ * @param {string} name
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ searchPlaces: async (name: string, options: RawAxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'name' is not null or undefined
+ assertParamExists('searchPlaces', 'name', name)
+ const localVarPath = `/search/places`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication cookie required
+
+ // authentication api_key required
+ await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+ if (name !== undefined) {
+ localVarQueryParameter['name'] = name;
+ }
+
+
+
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -15572,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
+ /**
+ *
+ * @param {string} name
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async searchPlaces(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.searchPlaces(name, options);
+ const index = configuration?.serverIndex ?? 0;
+ const operationBasePath = operationServerMap['SearchApi.searchPlaces']?.[index]?.url;
+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
+ },
/**
*
* @param {SmartSearchDto} smartSearchDto
@@ -15639,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise> {
return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
},
+ /**
+ *
+ * @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig): AxiosPromise> {
+ return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath));
+ },
/**
*
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
@@ -15805,6 +15920,20 @@ export interface SearchApiSearchPersonRequest {
readonly withHidden?: boolean
}
+/**
+ * Request parameters for searchPlaces operation in SearchApi.
+ * @export
+ * @interface SearchApiSearchPlacesRequest
+ */
+export interface SearchApiSearchPlacesRequest {
+ /**
+ *
+ * @type {string}
+ * @memberof SearchApiSearchPlaces
+ */
+ readonly name: string
+}
+
/**
* Request parameters for searchSmart operation in SearchApi.
* @export
@@ -15881,6 +16010,17 @@ export class SearchApi extends BaseAPI {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
}
+ /**
+ *
+ * @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof SearchApi
+ */
+ public searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig) {
+ return SearchApiFp(this.configuration).searchPlaces(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
+ }
+
/**
*
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
diff --git a/open-api/typescript-sdk/axios-client/base.ts b/open-api/typescript-sdk/axios-client/base.ts
index d16f428a39..d353309457 100644
--- a/open-api/typescript-sdk/axios-client/base.ts
+++ b/open-api/typescript-sdk/axios-client/base.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/axios-client/common.ts b/open-api/typescript-sdk/axios-client/common.ts
index 743c3cf16b..120ebca552 100644
--- a/open-api/typescript-sdk/axios-client/common.ts
+++ b/open-api/typescript-sdk/axios-client/common.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/axios-client/configuration.ts b/open-api/typescript-sdk/axios-client/configuration.ts
index 0e2dec06f5..cd67c859c7 100644
--- a/open-api/typescript-sdk/axios-client/configuration.ts
+++ b/open-api/typescript-sdk/axios-client/configuration.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/axios-client/index.ts b/open-api/typescript-sdk/axios-client/index.ts
index ccee244935..0918c8124d 100644
--- a/open-api/typescript-sdk/axios-client/index.ts
+++ b/open-api/typescript-sdk/axios-client/index.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts
index 9b3a359863..d023f8ef0a 100644
Binary files a/open-api/typescript-sdk/fetch-client.ts and b/open-api/typescript-sdk/fetch-client.ts differ
diff --git a/open-api/typescript-sdk/fetch-errors.ts b/open-api/typescript-sdk/fetch-errors.ts
new file mode 100644
index 0000000000..f21f0ed1c4
--- /dev/null
+++ b/open-api/typescript-sdk/fetch-errors.ts
@@ -0,0 +1,15 @@
+import { HttpError } from '@oazapfts/runtime';
+
+export interface ApiExceptionResponse {
+ message: string;
+ error?: string;
+ statusCode: number;
+}
+
+export interface ApiHttpError extends HttpError {
+ data: ApiExceptionResponse;
+}
+
+export function isHttpError(error: unknown): error is ApiHttpError {
+ return error instanceof HttpError;
+}
diff --git a/open-api/typescript-sdk/fetch.ts b/open-api/typescript-sdk/fetch.ts
index 5441cd8268..5759e66ad9 100644
--- a/open-api/typescript-sdk/fetch.ts
+++ b/open-api/typescript-sdk/fetch.ts
@@ -1 +1,2 @@
export * from './fetch-client';
+export * from './fetch-errors';
diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json
index 5346a47086..a918e2d2c9 100644
--- a/open-api/typescript-sdk/package-lock.json
+++ b/open-api/typescript-sdk/package-lock.json
@@ -29,9 +29,9 @@
"dev": true
},
"node_modules/@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
diff --git a/server/Dockerfile b/server/Dockerfile
index 9a7fc31fa2..7ea2795ea7 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,5 +1,5 @@
# dev build
-FROM ghcr.io/immich-app/base-server-dev:20240213@sha256:16646a37bae065b51e68cb2ba7a63027b29504d43a30644625382afbe326114a as dev
+FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -40,7 +40,7 @@ RUN npm run build
# prod build
-FROM ghcr.io/immich-app/base-server-prod:20240213@sha256:61d159d069c5b522f16de9733fb79feb0e82c0b099d16f026196f344d12a1e5e
+FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c
WORKDIR /usr/src/app
ENV NODE_ENV=production \
diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts
index 5993a70400..0e09a68be5 100644
--- a/server/e2e/api/specs/asset.e2e-spec.ts
+++ b/server/e2e/api/specs/asset.e2e-spec.ts
@@ -50,6 +50,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let asset3: AssetResponseDto;
let asset4: AssetResponseDto;
let asset5: AssetResponseDto;
+ let asset6: AssetResponseDto;
const createAsset = async (
loginResponse: LoginResponseDto,
@@ -96,12 +97,11 @@ describe(`${AssetController.name} (e2e)`, () => {
beforeEach(async () => {
await testApp.reset({ entities: [AssetEntity, AssetStackEntity] });
- [asset1, asset2, asset3, asset4, asset5] = await Promise.all([
+ [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([
createAsset(user1, new Date('1970-01-01')),
createAsset(user1, new Date('1970-02-10')),
createAsset(user1, new Date('1970-02-11'), {
isFavorite: true,
- isArchived: true,
isExternal: true,
isReadOnly: true,
type: AssetType.VIDEO,
@@ -118,6 +118,9 @@ describe(`${AssetController.name} (e2e)`, () => {
createAsset(user1, new Date('1970-01-01'), {
deletedAt: yesterday.toJSDate(),
}),
+ createAsset(user1, new Date('1970-02-11'), {
+ isArchived: true,
+ }),
]);
await assetRepository.upsertExif({
@@ -275,14 +278,14 @@ describe(`${AssetController.name} (e2e)`, () => {
should: 'should search by isArchived (true)',
deferred: () => ({
query: { isArchived: true },
- assets: [asset3],
+ assets: [asset6],
}),
},
{
should: 'should search by isArchived (false)',
deferred: () => ({
query: { isArchived: false },
- assets: [asset2, asset1],
+ assets: [asset3, asset2, asset1],
}),
},
{
@@ -313,6 +316,20 @@ describe(`${AssetController.name} (e2e)`, () => {
assets: [asset3],
}),
},
+ {
+ should: 'should search by withArchived (true)',
+ deferred: () => ({
+ query: { withArchived: true },
+ assets: [asset3, asset6, asset2, asset1],
+ }),
+ },
+ {
+ should: 'should search by withArchived (false)',
+ deferred: () => ({
+ query: { withArchived: false },
+ assets: [asset3, asset2, asset1],
+ }),
+ },
{
should: 'should search by createdBefore',
deferred: () => ({
@@ -514,8 +531,8 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toBe(200);
expect(body.length).toBe(assets.length);
- for (let i = 0; i < assets.length; i++) {
- expect(body[i]).toEqual(expect.objectContaining({ id: assets[i].id }));
+ for (const [i, asset] of assets.entries()) {
+ expect(body[i]).toEqual(expect.objectContaining({ id: asset.id }));
}
});
}
@@ -682,7 +699,7 @@ describe(`${AssetController.name} (e2e)`, () => {
it("should not upload to another user's library", async () => {
const content = randomBytes(32);
- const library = (await api.libraryApi.getAll(server, user2.accessToken))[0];
+ const [library] = await api.libraryApi.getAll(server, user2.accessToken);
await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
const { body, status } = await request(server)
@@ -902,7 +919,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`);
- expect(body).toEqual({ images: 5, videos: 1, total: 6 });
+ expect(body).toEqual({ images: 6, videos: 1, total: 7 });
expect(status).toBe(200);
});
@@ -923,7 +940,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isArchived: true });
expect(status).toBe(200);
- expect(body).toEqual({ images: 2, videos: 1, total: 3 });
+ expect(body).toEqual({ images: 3, videos: 0, total: 3 });
});
it('should return stats of all favored and archived assets', async () => {
@@ -933,7 +950,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isFavorite: true, isArchived: true });
expect(status).toBe(200);
- expect(body).toEqual({ images: 1, videos: 1, total: 2 });
+ expect(body).toEqual({ images: 1, videos: 0, total: 1 });
});
it('should return stats of all assets neither favored nor archived', async () => {
@@ -1041,7 +1058,7 @@ describe(`${AssetController.name} (e2e)`, () => {
expect.arrayContaining([
{ count: 1, timeBucket: '2023-11-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
- { count: 1, timeBucket: '1970-02-01T00:00:00.000Z' },
+ { count: 2, timeBucket: '1970-02-01T00:00:00.000Z' },
]),
);
});
@@ -1198,8 +1215,13 @@ describe(`${AssetController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset2.id })]));
+ expect(body).toHaveLength(2);
+ expect(body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: asset2.id }),
+ expect.objectContaining({ id: asset3.id }),
+ ]),
+ );
});
it('should get all map markers', async () => {
@@ -1209,8 +1231,13 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isArchived: false });
expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
+ expect(body).toHaveLength(2);
+ expect(body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: asset2.id }),
+ expect.objectContaining({ id: asset3.id }),
+ ]),
+ );
});
});
diff --git a/server/e2e/api/specs/person.e2e-spec.ts b/server/e2e/api/specs/person.e2e-spec.ts
deleted file mode 100644
index 73adcfab71..0000000000
--- a/server/e2e/api/specs/person.e2e-spec.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { IPersonRepository, LoginResponseDto } from '@app/domain';
-import { PersonController } from '@app/immich';
-import { PersonEntity } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
-import { errorStub, uuidStub } from '@test/fixtures';
-import request from 'supertest';
-import { api } from '../../client';
-import { testApp } from '../utils';
-
-describe(`${PersonController.name}`, () => {
- let app: INestApplication;
- let server: any;
- let loginResponse: LoginResponseDto;
- let accessToken: string;
- let personRepository: IPersonRepository;
- let visiblePerson: PersonEntity;
- let hiddenPerson: PersonEntity;
-
- beforeAll(async () => {
- app = await testApp.create();
- server = app.getHttpServer();
- personRepository = app.get(IPersonRepository);
- });
-
- afterAll(async () => {
- await testApp.teardown();
- });
-
- beforeEach(async () => {
- await testApp.reset();
- await api.authApi.adminSignUp(server);
- loginResponse = await api.authApi.adminLogin(server);
- accessToken = loginResponse.accessToken;
-
- const faceAsset = await api.assetApi.upload(server, accessToken, 'face_asset');
- visiblePerson = await personRepository.create({
- ownerId: loginResponse.userId,
- name: 'visible_person',
- thumbnailPath: '/thumbnail/face_asset',
- });
- await personRepository.createFaces([
- {
- assetId: faceAsset.id,
- personId: visiblePerson.id,
- embedding: Array.from({ length: 512 }, Math.random),
- },
- ]);
-
- hiddenPerson = await personRepository.create({
- ownerId: loginResponse.userId,
- name: 'hidden_person',
- isHidden: true,
- thumbnailPath: '/thumbnail/face_asset',
- });
- await personRepository.createFaces([
- {
- assetId: faceAsset.id,
- personId: hiddenPerson.id,
- embedding: Array.from({ length: 512 }, Math.random),
- },
- ]);
- });
-
- describe('GET /person', () => {
- beforeEach(async () => {});
-
- it('should require authentication', async () => {
- const { status, body } = await request(server).get('/person');
-
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- it('should return all people (including hidden)', async () => {
- const { status, body } = await request(server)
- .get('/person')
- .set('Authorization', `Bearer ${accessToken}`)
- .query({ withHidden: true });
-
- expect(status).toBe(200);
- expect(body).toEqual({
- total: 2,
- people: [
- expect.objectContaining({ name: 'visible_person' }),
- expect.objectContaining({ name: 'hidden_person' }),
- ],
- });
- });
-
- it('should return only visible people', async () => {
- const { status, body } = await request(server).get('/person').set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- total: 2,
- people: [expect.objectContaining({ name: 'visible_person' })],
- });
- });
- });
-
- describe('GET /person/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(server).get(`/person/${uuidStub.notFound}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- it('should throw error if person with id does not exist', async () => {
- const { status, body } = await request(server)
- .get(`/person/${uuidStub.notFound}`)
- .set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest());
- });
-
- it('should return person information', async () => {
- const { status, body } = await request(server)
- .get(`/person/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
- });
- });
-
- describe('PUT /person/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`);
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- for (const { key, type } of [
- { key: 'name', type: 'string' },
- { key: 'featureFaceAssetId', type: 'string' },
- { key: 'isHidden', type: 'boolean value' },
- ]) {
- it(`should not allow null ${key}`, async () => {
- const { status, body } = await request(server)
- .put(`/person/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ [key]: null });
- expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest([`${key} must be a ${type}`]));
- });
- }
-
- it('should not accept invalid birth dates', async () => {
- for (const { birthDate, response } of [
- { birthDate: false, response: 'Not found or no person.write access' },
- { birthDate: 'false', response: ['birthDate must be a Date instance'] },
- { birthDate: '123567', response: 'Not found or no person.write access' },
- { birthDate: 123567, response: 'Not found or no person.write access' },
- ]) {
- const { status, body } = await request(server)
- .put(`/person/${uuidStub.notFound}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ birthDate });
- expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest(response));
- }
- });
-
- it('should update a date of birth', async () => {
- const { status, body } = await request(server)
- .put(`/person/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ birthDate: '1990-01-01T05:00:00.000Z' });
- expect(status).toBe(200);
- expect(body).toMatchObject({ birthDate: '1990-01-01' });
- });
-
- it('should clear a date of birth', async () => {
- const person = await personRepository.create({
- birthDate: new Date('1990-01-01'),
- ownerId: loginResponse.userId,
- });
-
- expect(person.birthDate).toBeDefined();
-
- const { status, body } = await request(server)
- .put(`/person/${person.id}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ birthDate: null });
- expect(status).toBe(200);
- expect(body).toMatchObject({ birthDate: null });
- });
- });
-});
diff --git a/server/e2e/api/specs/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts
index 74988396d7..0e5cc428cc 100644
--- a/server/e2e/api/specs/search.e2e-spec.ts
+++ b/server/e2e/api/specs/search.e2e-spec.ts
@@ -44,7 +44,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (exif)', () => {
beforeEach(async () => {
- const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
+ const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
@@ -166,7 +166,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (smart info)', () => {
beforeEach(async () => {
- const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
+ const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
@@ -215,7 +215,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (file name)', () => {
beforeEach(async () => {
- const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
+ const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
diff --git a/server/e2e/client/activity-api.ts b/server/e2e/client/activity-api.ts
deleted file mode 100644
index f7cac45624..0000000000
--- a/server/e2e/client/activity-api.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { ActivityCreateDto, ActivityResponseDto } from '@app/domain';
-import request from 'supertest';
-
-export const activityApi = {
- create: async (server: any, accessToken: string, dto: ActivityCreateDto) => {
- const res = await request(server).post('/activity').set('Authorization', `Bearer ${accessToken}`).send(dto);
- expect(res.status === 200 || res.status === 201).toBe(true);
- return res.body as ActivityResponseDto;
- },
- delete: async (server: any, accessToken: string, id: string) => {
- const res = await request(server).delete(`/activity/${id}`).set('Authorization', `Bearer ${accessToken}`);
- expect(res.status).toEqual(204);
- },
-};
diff --git a/server/e2e/client/album-api.ts b/server/e2e/client/album-api.ts
deleted file mode 100644
index 92c75dc64b..0000000000
--- a/server/e2e/client/album-api.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { AddUsersDto, AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain';
-import request from 'supertest';
-
-export const albumApi = {
- create: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
- const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto);
- expect(res.status).toEqual(201);
- return res.body as AlbumResponseDto;
- },
- addAssets: async (server: any, accessToken: string, id: string, dto: BulkIdsDto) => {
- const res = await request(server)
- .put(`/album/${id}/assets`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send(dto);
- expect(res.status).toEqual(200);
- return res.body as BulkIdResponseDto[];
- },
- addUsers: async (server: any, accessToken: string, id: string, dto: AddUsersDto) => {
- const res = await request(server).put(`/album/${id}/users`).set('Authorization', `Bearer ${accessToken}`).send(dto);
- expect(res.status).toEqual(200);
- return res.body as AlbumResponseDto;
- },
- getAllAlbums: async (server: any, accessToken: string) => {
- const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
- expect(res.status).toEqual(200);
- return res.body as AlbumResponseDto[];
- },
-};
diff --git a/server/e2e/client/api-key-api.ts b/server/e2e/client/api-key-api.ts
deleted file mode 100644
index a35f13f7d9..0000000000
--- a/server/e2e/client/api-key-api.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { APIKeyCreateResponseDto } from '@app/domain';
-import { apiKeyCreateStub } from '@test';
-import request from 'supertest';
-
-export const apiKeyApi = {
- createApiKey: async (server: any, accessToken: string) => {
- const { status, body } = await request(server)
- .post('/api-key')
- .set('Authorization', `Bearer ${accessToken}`)
- .send(apiKeyCreateStub);
-
- expect(status).toBe(201);
-
- return body as APIKeyCreateResponseDto;
- },
-};
diff --git a/server/e2e/client/auth-api.ts b/server/e2e/client/auth-api.ts
index 3043c941f2..f0206d3376 100644
--- a/server/e2e/client/auth-api.ts
+++ b/server/e2e/client/auth-api.ts
@@ -1,4 +1,4 @@
-import { AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
+import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
import request from 'supertest';
@@ -27,19 +27,4 @@ export const authApi = {
return body as LoginResponseDto;
},
- getAuthDevices: async (server: any, accessToken: string) => {
- const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
-
- expect(body).toEqual(expect.any(Array));
- expect(status).toBe(200);
-
- return body as AuthDeviceResponseDto[];
- },
- validateToken: async (server: any, accessToken: string) => {
- const { status, body } = await request(server)
- .post('/auth/validateToken')
- .set('Authorization', `Bearer ${accessToken}`);
- expect(body).toEqual({ authStatus: true });
- expect(status).toBe(200);
- },
};
diff --git a/server/e2e/client/index.ts b/server/e2e/client/index.ts
index b9c0f2ff38..b0464a34d8 100644
--- a/server/e2e/client/index.ts
+++ b/server/e2e/client/index.ts
@@ -1,25 +1,15 @@
-import { activityApi } from './activity-api';
-import { albumApi } from './album-api';
-import { apiKeyApi } from './api-key-api';
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
-import { partnerApi } from './partner-api';
-import { serverInfoApi } from './server-info-api';
import { sharedLinkApi } from './shared-link-api';
import { trashApi } from './trash-api';
import { userApi } from './user-api';
export const api = {
- activityApi,
authApi,
- apiKeyApi,
assetApi,
libraryApi,
- serverInfoApi,
sharedLinkApi,
trashApi,
- albumApi,
userApi,
- partnerApi,
};
diff --git a/server/e2e/client/partner-api.ts b/server/e2e/client/partner-api.ts
deleted file mode 100644
index 97a9558c5f..0000000000
--- a/server/e2e/client/partner-api.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { PartnerResponseDto } from '@app/domain';
-import request from 'supertest';
-
-export const partnerApi = {
- create: async (server: any, accessToken: string, id: string) => {
- const { status, body } = await request(server).post(`/partner/${id}`).set('Authorization', `Bearer ${accessToken}`);
- expect(status).toBe(201);
- return body as PartnerResponseDto;
- },
-};
diff --git a/server/e2e/client/server-info-api.ts b/server/e2e/client/server-info-api.ts
deleted file mode 100644
index f885bc856f..0000000000
--- a/server/e2e/client/server-info-api.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { ServerConfigDto } from '@app/domain';
-import request from 'supertest';
-
-export const serverInfoApi = {
- getConfig: async (server: any) => {
- const res = await request(server).get('/server-info/config');
- expect(res.status).toBe(200);
- return res.body as ServerConfigDto;
- },
-};
diff --git a/server/e2e/client/shared-link-api.ts b/server/e2e/client/shared-link-api.ts
index d6179f6b6f..c34093b0ac 100644
--- a/server/e2e/client/shared-link-api.ts
+++ b/server/e2e/client/shared-link-api.ts
@@ -10,11 +10,4 @@ export const sharedLinkApi = {
expect(status).toBe(201);
return body as SharedLinkResponseDto;
},
-
- getMySharedLink: async (server: any, key: string) => {
- const { status, body } = await request(server).get('/shared-link/me').query({ key });
-
- expect(status).toBe(200);
- return body as SharedLinkResponseDto;
- },
};
diff --git a/server/e2e/client/user-api.ts b/server/e2e/client/user-api.ts
index 5ed0838f75..9123b06219 100644
--- a/server/e2e/client/user-api.ts
+++ b/server/e2e/client/user-api.ts
@@ -18,16 +18,6 @@ export const userApi = {
return body as UserResponseDto;
},
- get: async (server: any, accessToken: string, id: string) => {
- const { status, body } = await request(server)
- .get(`/user/info/${id}`)
- .set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ id });
-
- return body as UserResponseDto;
- },
update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto);
@@ -39,12 +29,4 @@ export const userApi = {
setExternalPath: async (server: any, accessToken: string, id: string, externalPath: string) => {
return await userApi.update(server, accessToken, { id, externalPath });
},
- delete: async (server: any, accessToken: string, id: string) => {
- const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
-
- return body as UserResponseDto;
- },
};
diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts
index 5f6ffba311..c8b14d588a 100644
--- a/server/e2e/jobs/specs/formats.e2e-spec.ts
+++ b/server/e2e/jobs/specs/formats.e2e-spec.ts
@@ -1,7 +1,7 @@
import { LoginResponseDto } from '@app/domain';
import { AssetType } from '@app/infra/entities';
-import { readFile } from 'fs/promises';
-import { basename, join } from 'path';
+import { readFile } from 'node:fs/promises';
+import { basename, join } from 'node:path';
import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils';
import { api } from '../../client';
@@ -19,7 +19,7 @@ const JPEG = {
iso: 200,
fNumber: 11,
exposureTime: '1/160',
- fileSizeInByte: 53493,
+ fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
@@ -42,11 +42,11 @@ const tests = [
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
- longitude: -96.071625,
+ longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
- fileSizeInByte: 880703,
+ fileSizeInByte: 880_703,
exposureTime: '1/887',
iso: 20,
focalLength: 3.99,
@@ -66,7 +66,7 @@ const tests = [
exifImageHeight: 800,
latitude: null,
longitude: null,
- fileSizeInByte: 25408,
+ fileSizeInByte: 25_408,
},
},
},
@@ -84,7 +84,7 @@ const tests = [
fNumber: 10,
focalLength: 18,
iso: 100,
- fileSizeInByte: 9057784,
+ fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
@@ -106,7 +106,7 @@ const tests = [
fNumber: 11,
focalLength: 85,
iso: 200,
- fileSizeInByte: 15856335,
+ fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
index cb7dd5f894..0215a4976e 100644
--- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
+++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
@@ -1,7 +1,7 @@
import { LibraryResponseDto, LibraryService, LoginResponseDto } from '@app/domain';
import { AssetType, LibraryType } from '@app/infra/entities';
-import fs from 'fs/promises';
-import path from 'path';
+import fs from 'node:fs/promises';
+import path from 'node:path';
import {
IMMICH_TEST_ASSET_PATH,
IMMICH_TEST_ASSET_TEMP_PATH,
@@ -20,7 +20,8 @@ describe(`Library watcher (e2e)`, () => {
beforeAll(async () => {
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../config/library-watcher-e2e-config.json`);
- server = (await testApp.create()).getHttpServer();
+ const app = await testApp.create();
+ server = app.getHttpServer();
libraryService = testApp.get(LibraryService);
});
diff --git a/server/package-lock.json b/server/package-lock.json
index ae129549b3..97c9dca58e 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich",
- "version": "1.95.0",
+ "version": "1.95.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
- "version": "1.95.0",
+ "version": "1.95.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@babel/runtime": "^7.22.11",
@@ -31,8 +31,8 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
- "exiftool-vendored": "~24.4.0",
- "exiftool-vendored.pl": "12.73",
+ "exiftool-vendored": "~24.5.0",
+ "exiftool-vendored.pl": "12.76",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"glob": "^10.3.3",
@@ -2705,9 +2705,9 @@
}
},
"node_modules/@photostructure/tz-lookup": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.0.tgz",
- "integrity": "sha512-gM3Xrs+XhD8ojDN0TgybuzSjsQb9UvF8j9DvR75E2zHlJQNiOztzILvfhVwadgA8JJbSMNzE+kYUnwP8aQnlXw=="
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz",
+ "integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
@@ -3179,9 +3179,9 @@
}
},
"node_modules/@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -4280,9 +4280,9 @@
}
},
"node_modules/batch-cluster": {
- "version": "12.1.0",
- "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
- "integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
+ "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==",
"engines": {
"node": ">=14"
}
@@ -5998,34 +5998,34 @@
"dev": true
},
"node_modules/exiftool-vendored": {
- "version": "24.4.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.4.0.tgz",
- "integrity": "sha512-n9GjZ+t0sD4mFGyxVCuyVKkyc4wDQraGE9XE3TbWqJBResbIggMBwcYeo9Q5oVBXe2K8EA/WraWDTqHN+C+52g==",
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz",
+ "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==",
"dependencies": {
- "@photostructure/tz-lookup": "^9.0.0",
- "@types/luxon": "^3.4.1",
- "batch-cluster": "^12.1.0",
+ "@photostructure/tz-lookup": "^9.0.1",
+ "@types/luxon": "^3.4.2",
+ "batch-cluster": "^13.0.0",
"he": "^1.2.0",
"luxon": "^3.4.4"
},
"optionalDependencies": {
- "exiftool-vendored.exe": "12.73.0",
- "exiftool-vendored.pl": "12.73.0"
+ "exiftool-vendored.exe": "12.76.0",
+ "exiftool-vendored.pl": "12.76.0"
}
},
"node_modules/exiftool-vendored.exe": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.73.0.tgz",
- "integrity": "sha512-7za1Iv1hBnO92A+Yua04PHicCcwrP4edCzHaYnDOuea2DhmpIhqCgUUgrShcm4tG58ueRBa3GVvhRW/gCm8n4g==",
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz",
+ "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==",
"optional": true,
"os": [
"win32"
]
},
"node_modules/exiftool-vendored.pl": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.73.0.tgz",
- "integrity": "sha512-qX6kiGUTuQ/HFwuP3VJHU///BSwlaHSWm+yrDTHHQG+w+yvjFdtajDTJ96CUvPA/ecehtbTUMqCUz5xgMmHfBw==",
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz",
+ "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==",
"os": [
"!win32"
]
@@ -14280,9 +14280,9 @@
}
},
"@photostructure/tz-lookup": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.0.tgz",
- "integrity": "sha512-gM3Xrs+XhD8ojDN0TgybuzSjsQb9UvF8j9DvR75E2zHlJQNiOztzILvfhVwadgA8JJbSMNzE+kYUnwP8aQnlXw=="
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz",
+ "integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog=="
},
"@pkgjs/parseargs": {
"version": "0.11.0",
@@ -14730,9 +14730,9 @@
}
},
"@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"requires": {
"undici-types": "~5.26.4"
}
@@ -15601,9 +15601,9 @@
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="
},
"batch-cluster": {
- "version": "12.1.0",
- "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
- "integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg=="
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
+ "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og=="
},
"bcrypt": {
"version": "5.1.1",
@@ -16841,15 +16841,15 @@
}
},
"exiftool-vendored": {
- "version": "24.4.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.4.0.tgz",
- "integrity": "sha512-n9GjZ+t0sD4mFGyxVCuyVKkyc4wDQraGE9XE3TbWqJBResbIggMBwcYeo9Q5oVBXe2K8EA/WraWDTqHN+C+52g==",
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz",
+ "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==",
"requires": {
- "@photostructure/tz-lookup": "^9.0.0",
- "@types/luxon": "^3.4.1",
- "batch-cluster": "^12.1.0",
- "exiftool-vendored.exe": "12.73.0",
- "exiftool-vendored.pl": "12.73.0",
+ "@photostructure/tz-lookup": "^9.0.1",
+ "@types/luxon": "^3.4.2",
+ "batch-cluster": "^13.0.0",
+ "exiftool-vendored.exe": "12.76.0",
+ "exiftool-vendored.pl": "12.76.0",
"he": "^1.2.0",
"luxon": "^3.4.4"
},
@@ -16862,15 +16862,15 @@
}
},
"exiftool-vendored.exe": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.73.0.tgz",
- "integrity": "sha512-7za1Iv1hBnO92A+Yua04PHicCcwrP4edCzHaYnDOuea2DhmpIhqCgUUgrShcm4tG58ueRBa3GVvhRW/gCm8n4g==",
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz",
+ "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==",
"optional": true
},
"exiftool-vendored.pl": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.73.0.tgz",
- "integrity": "sha512-qX6kiGUTuQ/HFwuP3VJHU///BSwlaHSWm+yrDTHHQG+w+yvjFdtajDTJ96CUvPA/ecehtbTUMqCUz5xgMmHfBw=="
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz",
+ "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA=="
},
"exit": {
"version": "0.1.2",
diff --git a/server/package.json b/server/package.json
index 5ea2a345df..e7d84dc5af 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "immich",
- "version": "1.95.0",
+ "version": "1.95.1",
"description": "",
"author": "",
"private": true,
@@ -56,8 +56,8 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
- "exiftool-vendored": "~24.4.0",
- "exiftool-vendored.pl": "12.73",
+ "exiftool-vendored": "~24.5.0",
+ "exiftool-vendored.pl": "12.76",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"glob": "^10.3.3",
diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts
index 325bb8ea4c..f328b5dcf6 100644
--- a/server/src/domain/asset/asset.service.ts
+++ b/server/src/domain/asset/asset.service.ts
@@ -326,7 +326,7 @@ export class AssetService {
const stackIdsToCheckForDelete: string[] = [];
if (removeParent) {
(options as Partial).stack = null;
- const assets = await this.assetRepository.getByIds(ids);
+ const assets = await this.assetRepository.getByIds(ids, { stack: true });
stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!)));
// This updates the updatedAt column of the parents to indicate that one of its children is removed
// All the unique parent's -> parent is set to null
diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts
index 94a9f8a42d..4cc0bd6672 100644
--- a/server/src/domain/asset/response-dto/asset-response.dto.ts
+++ b/server/src/domain/asset/response-dto/asset-response.dto.ts
@@ -73,23 +73,21 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
- const sanitizedAssetResponse: SanitizedAssetResponseDto = {
- id: entity.id,
- type: entity.type,
- thumbhash: entity.thumbhash?.toString('base64') ?? null,
- localDateTime: entity.localDateTime,
- resized: !!entity.resizePath,
- duration: entity.duration ?? '0:00:00.00000',
- livePhotoVideoId: entity.livePhotoVideoId,
- hasMetadata: false,
- };
-
if (stripMetadata) {
+ const sanitizedAssetResponse: SanitizedAssetResponseDto = {
+ id: entity.id,
+ type: entity.type,
+ thumbhash: entity.thumbhash?.toString('base64') ?? null,
+ localDateTime: entity.localDateTime,
+ resized: !!entity.resizePath,
+ duration: entity.duration ?? '0:00:00.00000',
+ livePhotoVideoId: entity.livePhotoVideoId,
+ hasMetadata: false,
+ };
return sanitizedAssetResponse as AssetResponseDto;
}
return {
- ...sanitizedAssetResponse,
id: entity.id,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId,
diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts
index 887b72e2cd..a7c003fad6 100644
--- a/server/src/domain/audit/audit.service.ts
+++ b/server/src/domain/audit/audit.service.ts
@@ -167,7 +167,7 @@ export class AuditService {
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
);
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
- this.assetRepository.getAll(options, { withDeleted: true }),
+ this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }),
);
let assetCount = 0;
diff --git a/server/src/domain/database/database.service.ts b/server/src/domain/database/database.service.ts
index 8cd08acd75..d697d032b3 100644
--- a/server/src/domain/database/database.service.ts
+++ b/server/src/domain/database/database.service.ts
@@ -72,7 +72,7 @@ export class DatabaseService {
In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${this.vectorExt}' manually as a superuser.
See https://immich.app/docs/guides/database-queries for how to query the database.
- Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherExt}'.
+ Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${extName[otherExt]}'.
Note that switching between the two extensions after a successful startup is not supported.
The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier.
In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.
diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts
index 4e7c4d5524..0dc9c54140 100644
--- a/server/src/domain/domain.constant.ts
+++ b/server/src/domain/domain.constant.ts
@@ -91,7 +91,7 @@ export const citiesFile = 'cities500.txt';
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.txt');
-export const geodataCitites500Path = join(GEODATA_ROOT_PATH, citiesFile);
+export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
const image: Record = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts
index aa48568b90..f4c9aa53e7 100644
--- a/server/src/domain/media/media.service.spec.ts
+++ b/server/src/domain/media/media.service.spec.ts
@@ -1,5 +1,6 @@
import {
AssetType,
+ AudioCodec,
Colorspace,
ExifEntity,
SystemConfigKey,
@@ -475,7 +476,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -542,7 +543,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -571,7 +572,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -629,7 +630,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -706,7 +707,10 @@ describe(MediaService.name, () => {
it('should copy video stream when video matches target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
- configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }]);
+ configMock.load.mockResolvedValue([
+ { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
+ { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
+ ]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -770,7 +774,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -836,7 +840,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -868,7 +872,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -897,7 +901,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -928,7 +932,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -962,7 +966,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -994,7 +998,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1026,7 +1030,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1057,7 +1061,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1087,7 +1091,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1117,7 +1121,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1147,7 +1151,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v hevc',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1181,7 +1185,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v hevc',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1248,7 +1252,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1286,7 +1290,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1320,7 +1324,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1355,7 +1359,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1386,7 +1390,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1418,7 +1422,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-c:v h264_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1455,7 +1459,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw'],
outputOptions: [
`-c:v h264_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1491,7 +1495,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-c:v h264_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1524,7 +1528,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-c:v vp9_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1568,7 +1572,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1600,7 +1604,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1634,7 +1638,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1664,7 +1668,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1690,7 +1694,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD129', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1724,7 +1728,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1757,7 +1761,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1798,7 +1802,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
`-c:v hevc_rkmpp_encoder`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1838,7 +1842,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
`-c:v h264_rkmpp_encoder`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1872,7 +1876,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1899,7 +1903,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1926,7 +1930,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts
index 94c3e9ae3f..562568adf6 100644
--- a/server/src/domain/metadata/metadata.service.ts
+++ b/server/src/domain/metadata/metadata.service.ts
@@ -493,7 +493,7 @@ export class MetadataService {
model: tags.Model ?? null,
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(tags.Orientation)?.toString() ?? null,
- profileDescription: tags.ProfileDescription || tags.ProfileName || null,
+ profileDescription: tags.ProfileDescription || null,
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
timeZone: tags.tz ?? null,
};
diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts
index 360a9b2348..b8ad8f0451 100644
--- a/server/src/domain/person/person.dto.ts
+++ b/server/src/domain/person/person.dto.ts
@@ -127,7 +127,8 @@ export class PersonStatisticsResponseDto {
export class PeopleResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
-
+ @ApiProperty({ type: 'integer' })
+ hidden!: number;
people!: PersonResponseDto[];
}
diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts
index 5da8666016..ffda9034bd 100644
--- a/server/src/domain/person/person.service.spec.ts
+++ b/server/src/domain/person/person.service.spec.ts
@@ -114,35 +114,12 @@ describe(PersonService.name, () => {
});
describe('getAll', () => {
- it('should get all people with thumbnails', async () => {
- personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
- personMock.getNumberOfPeople.mockResolvedValue(1);
- await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({
- total: 1,
- people: [responseDto],
- });
- expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, {
- minimumFaceCount: 3,
- withHidden: false,
- });
- });
- it('should get all visible people with thumbnails', async () => {
- personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]);
- personMock.getNumberOfPeople.mockResolvedValue(2);
- await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({
- total: 2,
- people: [responseDto],
- });
- expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, {
- minimumFaceCount: 3,
- withHidden: false,
- });
- });
it('should get all hidden and visible people with thumbnails', async () => {
personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]);
- personMock.getNumberOfPeople.mockResolvedValue(2);
+ personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
total: 2,
+ hidden: 1,
people: [
responseDto,
{
diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts
index 6fbc409bf8..6300cc743c 100644
--- a/server/src/domain/person/person.service.ts
+++ b/server/src/domain/person/person.service.ts
@@ -82,15 +82,12 @@ export class PersonService {
minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden: dto.withHidden || false,
});
- const total = await this.repository.getNumberOfPeople(auth.user.id);
- const persons: PersonResponseDto[] = people
- // with thumbnails
- .filter((person) => !!person.thumbnailPath)
- .map((person) => mapPerson(person));
+ const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id);
return {
- people: persons.filter((person) => dto.withHidden || !person.isHidden),
+ people: people.map((person) => mapPerson(person)),
total,
+ hidden,
};
}
diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts
index 80240091a9..85c11fe921 100644
--- a/server/src/domain/repositories/person.repository.ts
+++ b/server/src/domain/repositories/person.repository.ts
@@ -28,6 +28,11 @@ export interface PersonStatistics {
assets: number;
}
+export interface PeopleStatistics {
+ total: number;
+ hidden: number;
+}
+
export interface IPersonRepository {
getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated;
getAllForUser(userId: string, options: PersonSearchOptions): Promise;
@@ -54,7 +59,7 @@ export interface IPersonRepository {
getRandomFace(personId: string): Promise;
getStatistics(personId: string): Promise;
reassignFace(assetFaceId: string, newPersonId: string): Promise;
- getNumberOfPeople(userId: string): Promise;
+ getNumberOfPeople(userId: string): Promise;
reassignFaces(data: UpdateFacesData): Promise;
update(entity: Partial): Promise;
}
diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts
index 7183e9e3fe..8566fcd8e5 100644
--- a/server/src/domain/repositories/search.repository.ts
+++ b/server/src/domain/repositories/search.repository.ts
@@ -1,4 +1,4 @@
-import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities';
+import { AssetEntity, AssetFaceEntity, AssetType, GeodataPlacesEntity, SmartInfoEntity } from '@app/infra/entities';
import { Paginated } from '../domain.util';
export const ISearchRepository = 'ISearchRepository';
@@ -186,4 +186,5 @@ export interface ISearchRepository {
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated;
searchFaces(search: FaceEmbeddingSearch): Promise;
upsert(smartInfo: Partial, embedding?: Embedding): Promise;
+ searchPlaces(placeName: string): Promise;
}
diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts
index 5aa73433d9..877a494e4d 100644
--- a/server/src/domain/search/dto/search.dto.ts
+++ b/server/src/domain/search/dto/search.dto.ts
@@ -1,5 +1,5 @@
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
-import { AssetType } from '@app/infra/entities';
+import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
@@ -23,6 +23,7 @@ class BaseSearchDto {
isArchived?: boolean;
@QueryBoolean({ optional: true })
+ @ApiProperty({ default: false })
withArchived?: boolean;
@QueryBoolean({ optional: true })
@@ -118,6 +119,9 @@ class BaseSearchDto {
@Type(() => Number)
@Optional()
size?: number;
+
+ @QueryBoolean({ optional: true })
+ isNotInAlbum?: boolean;
}
export class MetadataSearchDto extends BaseSearchDto {
@@ -170,9 +174,6 @@ export class MetadataSearchDto extends BaseSearchDto {
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
order?: AssetOrder;
- @QueryBoolean({ optional: true })
- isNotInAlbum?: boolean;
-
@Optional()
personIds?: string[];
}
@@ -240,6 +241,12 @@ export class SearchDto {
size?: number;
}
+export class SearchPlacesDto {
+ @IsString()
+ @IsNotEmpty()
+ name!: string;
+}
+
export class SearchPeopleDto {
@IsString()
@IsNotEmpty()
@@ -250,3 +257,21 @@ export class SearchPeopleDto {
@Optional()
withHidden?: boolean;
}
+
+export class PlacesResponseDto {
+ name!: string;
+ latitude!: number;
+ longitude!: number;
+ admin1name?: string;
+ admin2name?: string;
+}
+
+export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto {
+ return {
+ name: place.name,
+ latitude: place.latitude,
+ longitude: place.longitude,
+ admin1name: place.admin1Name,
+ admin2name: place.admin2Name,
+ };
+}
diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts
index 452c556f41..5b56399981 100644
--- a/server/src/domain/search/search.service.ts
+++ b/server/src/domain/search/search.service.ts
@@ -16,7 +16,15 @@ import {
SearchStrategy,
} from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config';
-import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
+import {
+ MetadataSearchDto,
+ PlacesResponseDto,
+ SearchDto,
+ SearchPeopleDto,
+ SearchPlacesDto,
+ SmartSearchDto,
+ mapPlaces,
+} from './dto';
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
import { SearchResponseDto } from './response-dto';
@@ -41,6 +49,11 @@ export class SearchService {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
}
+ async searchPlaces(dto: SearchPlacesDto): Promise {
+ const places = await this.searchRepository.searchPlaces(dto.name);
+ return places.map((place) => mapPlaces(place));
+ }
+
async getExploreData(auth: AuthDto): Promise[]> {
await this.configCore.requireFeature(FeatureFlag.SEARCH);
const options = { maxFields: 12, minAssetsPerField: 5 };
@@ -182,26 +195,22 @@ export class SearchService {
}
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise {
- if (dto.type === SearchSuggestionType.COUNTRY) {
- return this.metadataRepository.getCountries(auth.user.id);
+ switch (dto.type) {
+ case SearchSuggestionType.COUNTRY: {
+ return this.metadataRepository.getCountries(auth.user.id);
+ }
+ case SearchSuggestionType.STATE: {
+ return this.metadataRepository.getStates(auth.user.id, dto.country);
+ }
+ case SearchSuggestionType.CITY: {
+ return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
+ }
+ case SearchSuggestionType.CAMERA_MAKE: {
+ return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
+ }
+ case SearchSuggestionType.CAMERA_MODEL: {
+ return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
+ }
}
-
- if (dto.type === SearchSuggestionType.STATE) {
- return this.metadataRepository.getStates(auth.user.id, dto.country);
- }
-
- if (dto.type === SearchSuggestionType.CITY) {
- return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
- }
-
- if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
- return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
- }
-
- if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
- return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
- }
-
- return [];
}
}
diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts
index d696982540..857d1df327 100644
--- a/server/src/domain/storage-template/storage-template.service.ts
+++ b/server/src/domain/storage-template/storage-template.service.ts
@@ -117,7 +117,7 @@ export class StorageTemplateService {
return true;
}
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
- this.assetRepository.getAll(pagination),
+ this.assetRepository.getAll(pagination, { withExif: true }),
);
const users = await this.userRepository.getList();
diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts
index 1591e87d63..eb661133b2 100644
--- a/server/src/domain/system-config/system-config.core.ts
+++ b/server/src/domain/system-config/system-config.core.ts
@@ -33,7 +33,7 @@ export const defaults = Object.freeze({
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
targetAudioCodec: AudioCodec.AAC,
- acceptedAudioCodecs: [AudioCodec.AAC],
+ acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS],
targetResolution: '720',
maxBitrate: '0',
bframes: -1,
diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts
index 8addc63a0f..ec0b4b8f4f 100644
--- a/server/src/domain/system-config/system-config.service.spec.ts
+++ b/server/src/domain/system-config/system-config.service.spec.ts
@@ -43,7 +43,7 @@ const updatedConfig = Object.freeze({
threads: 0,
preset: 'ultrafast',
targetAudioCodec: AudioCodec.AAC,
- acceptedAudioCodecs: [AudioCodec.AAC],
+ acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS],
targetResolution: '720',
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts
index 4e57cfaa62..b807da9665 100644
--- a/server/src/immich/controllers/search.controller.ts
+++ b/server/src/immich/controllers/search.controller.ts
@@ -2,9 +2,11 @@ import {
AuthDto,
MetadataSearchDto,
PersonResponseDto,
+ PlacesResponseDto,
SearchDto,
SearchExploreResponseDto,
SearchPeopleDto,
+ SearchPlacesDto,
SearchResponseDto,
SearchService,
SmartSearchDto,
@@ -48,6 +50,11 @@ export class SearchController {
return this.service.searchPerson(auth, dto);
}
+ @Get('places')
+ searchPlaces(@Query() dto: SearchPlacesDto): Promise {
+ return this.service.searchPlaces(dto);
+ }
+
@Get('suggestions')
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise {
return this.service.getSearchSuggestions(auth, dto);
diff --git a/server/src/infra/entities/geodata-admin1.entity.ts b/server/src/infra/entities/geodata-admin1.entity.ts
deleted file mode 100644
index 36cf0a805e..0000000000
--- a/server/src/infra/entities/geodata-admin1.entity.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Column, Entity, PrimaryColumn } from 'typeorm';
-
-@Entity('geodata_admin1')
-export class GeodataAdmin1Entity {
- @PrimaryColumn({ type: 'varchar' })
- key!: string;
-
- @Column({ type: 'varchar' })
- name!: string;
-}
diff --git a/server/src/infra/entities/geodata-admin2.entity.ts b/server/src/infra/entities/geodata-admin2.entity.ts
deleted file mode 100644
index bd03e83776..0000000000
--- a/server/src/infra/entities/geodata-admin2.entity.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Column, Entity, PrimaryColumn } from 'typeorm';
-
-@Entity('geodata_admin2')
-export class GeodataAdmin2Entity {
- @PrimaryColumn({ type: 'varchar' })
- key!: string;
-
- @Column({ type: 'varchar' })
- name!: string;
-}
diff --git a/server/src/infra/entities/geodata-places.entity.ts b/server/src/infra/entities/geodata-places.entity.ts
index 244e4261b0..966a50d5c9 100644
--- a/server/src/infra/entities/geodata-places.entity.ts
+++ b/server/src/infra/entities/geodata-places.entity.ts
@@ -1,6 +1,4 @@
-import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity';
-import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
-import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
+import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_places', { synchronize: false })
export class GeodataPlacesEntity {
@@ -21,7 +19,7 @@ export class GeodataPlacesEntity {
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
// type: 'earth',
// })
- earthCoord!: unknown;
+ // earthCoord!: unknown;
@Column({ type: 'char', length: 2 })
countryCode!: string;
@@ -32,27 +30,14 @@ export class GeodataPlacesEntity {
@Column({ type: 'varchar', length: 80, nullable: true })
admin2Code!: string;
- @Column({
- type: 'varchar',
- generatedType: 'STORED',
- asExpression: `"countryCode" || '.' || "admin1Code"`,
- nullable: true,
- })
- admin1Key!: string;
+ @Column({ type: 'varchar', nullable: true })
+ admin1Name!: string;
- @ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
- admin1!: GeodataAdmin1Entity;
+ @Column({ type: 'varchar', nullable: true })
+ admin2Name!: string;
- @Column({
- type: 'varchar',
- generatedType: 'STORED',
- asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`,
- nullable: true,
- })
- admin2Key!: string;
-
- @ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
- admin2!: GeodataAdmin2Entity;
+ @Column({ type: 'varchar', nullable: true })
+ alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts
index 957e15a887..af620790ef 100644
--- a/server/src/infra/entities/index.ts
+++ b/server/src/infra/entities/index.ts
@@ -7,8 +7,6 @@ import { AssetStackEntity } from './asset-stack.entity';
import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity';
-import { GeodataAdmin1Entity } from './geodata-admin1.entity';
-import { GeodataAdmin2Entity } from './geodata-admin2.entity';
import { GeodataPlacesEntity } from './geodata-places.entity';
import { LibraryEntity } from './library.entity';
import { MoveEntity } from './move.entity';
@@ -32,8 +30,6 @@ export * from './asset-stack.entity';
export * from './asset.entity';
export * from './audit.entity';
export * from './exif.entity';
-export * from './geodata-admin1.entity';
-export * from './geodata-admin2.entity';
export * from './geodata-places.entity';
export * from './library.entity';
export * from './move.entity';
@@ -59,8 +55,6 @@ export const databaseEntities = [
AuditEntity,
ExifEntity,
GeodataPlacesEntity,
- GeodataAdmin1Entity,
- GeodataAdmin2Entity,
MoveEntity,
PartnerEntity,
PersonEntity,
diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts
index 1515630cea..8307a0328e 100644
--- a/server/src/infra/entities/system-config.entity.ts
+++ b/server/src/infra/entities/system-config.entity.ts
@@ -7,7 +7,7 @@ export class SystemConfigEntity {
key!: SystemConfigKey;
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
- value!: T;
+ value!: T | T[];
}
export type SystemConfigValue = string | number | boolean;
diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts
index ab7d744317..745f5a38ff 100644
--- a/server/src/infra/infra.utils.ts
+++ b/server/src/infra/infra.utils.ts
@@ -183,7 +183,7 @@ export function searchAssetBuilder(
_.omitBy(
{
...status,
- isArchived: isArchived ?? withArchived,
+ isArchived: isArchived ?? (withArchived ? undefined : false),
encodedVideoPath: isEncoded ? Not(IsNull()) : undefined,
livePhotoVideoId: isMotion ? Not(IsNull()) : undefined,
},
@@ -213,9 +213,9 @@ export function searchAssetBuilder(
if (personIds && personIds.length > 0) {
builder
.leftJoin(`${builder.alias}.faces`, 'faces')
- .andWhere('faces.personId IN (:...personIds)', { personIds: personIds })
+ .andWhere('faces.personId IN (:...personIds)', { personIds })
.addGroupBy(`${builder.alias}.id`)
- .having('COUNT(faces.id) = :personCount', { personCount: personIds.length });
+ .having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length });
if (withExif) {
builder.addGroupBy('exifInfo.assetId');
diff --git a/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts b/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts
index e83e4b4fb0..11c84cf970 100644
--- a/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts
+++ b/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts
@@ -4,11 +4,11 @@ export class AddVectorsToSearchPath1707000751533 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise {
const res = await queryRunner.query(`SELECT current_database() as db`);
const databaseName = res[0]['db'];
- await queryRunner.query(`ALTER DATABASE ${databaseName} SET search_path TO "$user", public, vectors`);
+ await queryRunner.query(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`);
}
public async down(queryRunner: QueryRunner): Promise {
const databaseName = await queryRunner.query(`SELECT current_database()`);
- await queryRunner.query(`ALTER DATABASE ${databaseName} SET search_path TO "$user", public`);
+ await queryRunner.query(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public`);
}
}
diff --git a/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts
new file mode 100644
index 0000000000..136ca2598d
--- /dev/null
+++ b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts
@@ -0,0 +1,152 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class GeodataLocationSearch1708059341865 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS unaccent`);
+
+ // https://stackoverflow.com/a/11007216
+ await queryRunner.query(`
+ CREATE OR REPLACE FUNCTION f_unaccent(text)
+ RETURNS text
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
+ RETURN unaccent('unaccent', $1)`);
+
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin1Name" varchar`);
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin2Name" varchar`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin1Name" = admin1.name
+ FROM geodata_admin1 admin1
+ WHERE admin1.key = "admin1Key"`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin2Name" = admin2.name
+ FROM geodata_admin2 admin2
+ WHERE admin2.key = "admin2Key"`);
+
+ await queryRunner.query(`DROP TABLE geodata_admin1 CASCADE`);
+ await queryRunner.query(`DROP TABLE geodata_admin2 CASCADE`);
+
+ await queryRunner.query(`
+ ALTER TABLE geodata_places
+ DROP COLUMN "admin1Key",
+ DROP COLUMN "admin2Key"`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_name
+ ON geodata_places
+ USING gin (f_unaccent(name) gin_trgm_ops)`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin1_name
+ ON geodata_places
+ USING gin (f_unaccent("admin1Name") gin_trgm_ops)`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin2_name
+ ON geodata_places
+ USING gin (f_unaccent("admin2Name") gin_trgm_ops)`);
+
+ await queryRunner.query(
+ `
+ DELETE FROM "typeorm_metadata"
+ WHERE
+ "type" = $1 AND
+ "name" = $2 AND
+ "database" = $3 AND
+ "schema" = $4 AND
+ "table" = $5`,
+ ['GENERATED_COLUMN', 'admin1Key', 'immich', 'public', 'geodata_places'],
+ );
+
+ await queryRunner.query(
+ `
+ DELETE FROM "typeorm_metadata"
+ WHERE
+ "type" = $1 AND
+ "name" = $2 AND
+ "database" = $3 AND
+ "schema" = $4 AND
+ "table" = $5`,
+ ['GENERATED_COLUMN', 'admin2Key', 'immich', 'public', 'geodata_places'],
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE "geodata_admin1" (
+ "key" character varying NOT NULL,
+ "name" character varying NOT NULL,
+ CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key")
+ )`);
+
+ await queryRunner.query(`
+ CREATE TABLE "geodata_admin2" (
+ "key" character varying NOT NULL,
+ "name" character varying NOT NULL,
+ CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key")
+ )`);
+
+ await queryRunner.query(`
+ ALTER TABLE geodata_places
+ ADD COLUMN "admin1Key" character varying
+ GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED,
+ ADD COLUMN "admin2Key" character varying
+ GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED`);
+
+ await queryRunner.query(
+ `
+ INSERT INTO "geodata_admin1"
+ SELECT DISTINCT
+ "admin1Key" AS "key",
+ "admin1Name" AS "name"
+ FROM geodata_places
+ WHERE "admin1Name" IS NOT NULL`,
+ );
+
+ await queryRunner.query(
+ `
+ INSERT INTO "geodata_admin2"
+ SELECT DISTINCT
+ "admin2Key" AS "key",
+ "admin2Name" AS "name"
+ FROM geodata_places
+ WHERE "admin2Name" IS NOT NULL`,
+ );
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin1Name" = admin1.name
+ FROM geodata_admin1 admin1
+ WHERE admin1.key = "admin1Key"`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin2Name" = admin2.name
+ FROM geodata_admin2 admin2
+ WHERE admin2.key = "admin2Key";`);
+
+ await queryRunner.query(
+ `
+ INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ ['immich', 'public', 'geodata_places', 'GENERATED_COLUMN', 'admin1Key', '"countryCode" || \'.\' || "admin1Code"'],
+ );
+
+ await queryRunner.query(
+ `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [
+ 'immich',
+ 'public',
+ 'geodata_places',
+ 'GENERATED_COLUMN',
+ 'admin2Key',
+ '"countryCode" || \'.\' || "admin1Code" || \'.\' || "admin2Code"',
+ ],
+ );
+ }
+}
diff --git a/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts
new file mode 100644
index 0000000000..0cea9a0411
--- /dev/null
+++ b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts
@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class GeonamesEnhancement1708116312820 implements MigrationInterface {
+ name = 'GeonamesEnhancement1708116312820'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "alternateNames" varchar`);
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin2_alternate_names
+ ON geodata_places
+ USING gin (f_unaccent("alternateNames") gin_trgm_ops)`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "alternateNames"`);
+ }
+
+}
diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts
index 6a90ad1081..4abfe0eace 100644
--- a/server/src/infra/repositories/metadata.repository.ts
+++ b/server/src/infra/repositories/metadata.repository.ts
@@ -2,7 +2,7 @@ import {
citiesFile,
geodataAdmin1Path,
geodataAdmin2Path,
- geodataCitites500Path,
+ geodataCities500Path,
geodataDatePath,
GeoPoint,
IMetadataRepository,
@@ -10,13 +10,7 @@ import {
ISystemMetadataRepository,
ReverseGeocodeResult,
} from '@app/domain';
-import {
- ExifEntity,
- GeodataAdmin1Entity,
- GeodataAdmin2Entity,
- GeodataPlacesEntity,
- SystemMetadataKey,
-} from '@app/infra/entities';
+import { ExifEntity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@@ -26,19 +20,16 @@ import { getName } from 'i18n-iso-countries';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline';
-import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
+import { DataSource, QueryRunner, Repository } from 'typeorm';
+import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
import { DummyValue, GenerateSql } from '../infra.util';
-type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
-type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
-
export class MetadataRepository implements IMetadataRepository {
constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository,
- @InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository,
- @InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository,
- @Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
+ @Inject(ISystemMetadataRepository)
+ private readonly systemMetadataRepository: ISystemMetadataRepository,
@InjectDataSource() private dataSource: DataSource,
) {}
@@ -54,7 +45,6 @@ export class MetadataRepository implements IMetadataRepository {
return;
}
- this.logger.log('Importing geodata to database from file');
await this.importGeodata();
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
@@ -69,12 +59,14 @@ export class MetadataRepository implements IMetadataRepository {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
+ const admin1 = await this.loadAdmin(geodataAdmin1Path);
+ const admin2 = await this.loadAdmin(geodataAdmin2Path);
+
try {
await queryRunner.startTransaction();
- await this.loadCities500(queryRunner);
- await this.loadAdmin1(queryRunner);
- await this.loadAdmin2(queryRunner);
+ await queryRunner.manager.clear(GeodataPlacesEntity);
+ await this.loadCities500(queryRunner, admin1, admin2);
await queryRunner.commitTransaction();
} catch (error) {
@@ -86,76 +78,73 @@ export class MetadataRepository implements IMetadataRepository {
}
}
- private async loadGeodataToTableFromFile(
+ private async loadGeodataToTableFromFile(
queryRunner: QueryRunner,
- lineToEntityMapper: (lineSplit: string[]) => T,
+ lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity,
filePath: string,
- entity: GeoEntityClass,
) {
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`);
}
- await queryRunner.manager.clear(entity);
const input = createReadStream(filePath);
- let buffer: DeepPartial[] = [];
- const lineReader = readLine.createInterface({ input: input });
+ let bufferGeodata: QueryDeepPartialEntity[] = [];
+ const lineReader = readLine.createInterface({ input });
for await (const line of lineReader) {
const lineSplit = line.split('\t');
- buffer.push(lineToEntityMapper(lineSplit));
- if (buffer.length > 1000) {
- await queryRunner.manager.save(buffer);
- buffer = [];
+ const geoData = lineToEntityMapper(lineSplit);
+ bufferGeodata.push(geoData);
+ if (bufferGeodata.length > 1000) {
+ await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
+ bufferGeodata = [];
}
}
- await queryRunner.manager.save(buffer);
+ await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
}
- private async loadCities500(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
+ private async loadCities500(
+ queryRunner: QueryRunner,
+ admin1Map: Map,
+ admin2Map: Map,
+ ) {
+ await this.loadGeodataToTableFromFile(
queryRunner,
(lineSplit: string[]) =>
this.geodataPlacesRepository.create({
id: Number.parseInt(lineSplit[0]),
name: lineSplit[1],
+ alternateNames: lineSplit[3],
latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8],
admin1Code: lineSplit[10],
admin2Code: lineSplit[11],
modificationDate: lineSplit[18],
+ admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
+ admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
}),
- geodataCitites500Path,
- GeodataPlacesEntity,
+ geodataCities500Path,
);
}
- private async loadAdmin1(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
- queryRunner,
- (lineSplit: string[]) =>
- this.geodataAdmin1Repository.create({
- key: lineSplit[0],
- name: lineSplit[1],
- }),
- geodataAdmin1Path,
- GeodataAdmin1Entity,
- );
- }
+ private async loadAdmin(filePath: string) {
+ if (!existsSync(filePath)) {
+ this.logger.error(`Geodata file ${filePath} not found`);
+ throw new Error(`Geodata file ${filePath} not found`);
+ }
- private async loadAdmin2(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
- queryRunner,
- (lineSplit: string[]) =>
- this.geodataAdmin2Repository.create({
- key: lineSplit[0],
- name: lineSplit[1],
- }),
- geodataAdmin2Path,
- GeodataAdmin2Entity,
- );
+ const input = createReadStream(filePath);
+ const lineReader = readLine.createInterface({ input: input });
+
+ const adminMap = new Map();
+ for await (const line of lineReader) {
+ const lineSplit = line.split('\t');
+ adminMap.set(lineSplit[0], lineSplit[1]);
+ }
+
+ return adminMap;
}
async teardown() {
@@ -167,8 +156,6 @@ export class MetadataRepository implements IMetadataRepository {
const response = await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
- .leftJoinAndSelect('geoplaces.admin1', 'admin1')
- .leftJoinAndSelect('geoplaces.admin2', 'admin2')
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
.limit(1)
@@ -183,9 +170,9 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
- const { countryCode, name: city, admin1, admin2 } = response;
+ const { countryCode, name: city, admin1Name, admin2Name } = response;
const country = getName(countryCode, 'en') ?? null;
- const stateParts = [admin2?.name, admin1?.name].filter((name) => !!name);
+ const stateParts = [admin2Name, admin1Name].filter((name) => !!name);
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
return { country, state, city };
diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts
index 85423b74dd..63b3d570ef 100644
--- a/server/src/infra/repositories/person.repository.ts
+++ b/server/src/infra/repositories/person.repository.ts
@@ -3,6 +3,7 @@ import {
IPersonRepository,
Paginated,
PaginationOptions,
+ PeopleStatistics,
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
@@ -69,6 +70,7 @@ export class PersonRepository implements IPersonRepository {
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
.addOrderBy('COUNT(face.assetId)', 'DESC')
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
+ .andWhere("person.thumbnailPath != ''")
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id')
.limit(500);
@@ -207,15 +209,25 @@ export class PersonRepository implements IPersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
- async getNumberOfPeople(userId: string): Promise {
- return this.personRepository
+ async getNumberOfPeople(userId: string): Promise {
+ const items = await this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
+ .innerJoin('face.asset', 'asset')
+ .andWhere('asset.isArchived = false')
+ .andWhere("person.thumbnailPath != ''")
+ .select('COUNT(DISTINCT(person.id))', 'total')
+ .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden')
.having('COUNT(face.assetId) != 0')
- .groupBy('person.id')
- .withDeleted()
- .getCount();
+ .getRawOne();
+
+ const result: PeopleStatistics = {
+ total: items ? Number.parseInt(items.total) : 0,
+ hidden: items ? Number.parseInt(items.hidden) : 0,
+ };
+
+ return result;
}
create(entity: Partial): Promise {
diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts
index a30c96b10d..089640128c 100644
--- a/server/src/infra/repositories/search.repository.ts
+++ b/server/src/infra/repositories/search.repository.ts
@@ -12,7 +12,13 @@ import {
SmartSearchOptions,
} from '@app/domain';
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
-import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
+import {
+ AssetEntity,
+ AssetFaceEntity,
+ GeodataPlacesEntity,
+ SmartInfoEntity,
+ SmartSearchEntity,
+} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -31,6 +37,7 @@ export class SearchRepository implements ISearchRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository,
+ @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository,
) {
this.faceColumns = this.assetFaceRepository.manager.connection
.getMetadata(AssetFaceEntity)
@@ -172,6 +179,27 @@ export class SearchRepository implements ISearchRepository {
}));
}
+ @GenerateSql({ params: [DummyValue.STRING] })
+ async searchPlaces(placeName: string): Promise {
+ return await this.geodataPlacesRepository
+ .createQueryBuilder('geoplaces')
+ .where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
+ .orderBy(
+ `
+ COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
+ `,
+ )
+ .setParameters({ placeName })
+ .limit(20)
+ .getMany();
+ }
+
async upsert(smartInfo: Partial, embedding?: Embedding): Promise {
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
if (!smartInfo.assetId || !embedding) {
diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql
index d971129e75..e5cf6771fd 100644
--- a/server/src/infra/sql/asset.repository.sql
+++ b/server/src/infra/sql/asset.repository.sql
@@ -434,7 +434,7 @@ WHERE
AND 1 = 1
AND "asset"."ownerId" IN ($2)
AND 1 = 1
- AND 1 = 1
+ AND "asset"."isArchived" = $3
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql
index bd4a523e86..c2cc45ee88 100644
--- a/server/src/infra/sql/person.repository.sql
+++ b/server/src/infra/sql/person.repository.sql
@@ -26,6 +26,7 @@ FROM
WHERE
"person"."ownerId" = $1
AND "asset"."isArchived" = false
+ AND "person"."thumbnailPath" != ''
AND "person"."isHidden" = false
GROUP BY
"person"."id"
@@ -344,12 +345,20 @@ LIMIT
-- PersonRepository.getNumberOfPeople
SELECT
- COUNT(DISTINCT ("person"."id")) AS "cnt"
+ COUNT(DISTINCT ("person"."id")) AS "total",
+ COUNT(DISTINCT ("person"."id")) FILTER (
+ WHERE
+ "person"."isHidden" = true
+ ) AS "hidden"
FROM
"person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
+ INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
+ AND ("asset"."deletedAt" IS NULL)
WHERE
"person"."ownerId" = $1
+ AND "asset"."isArchived" = false
+ AND "person"."thumbnailPath" != ''
HAVING
COUNT("face"."assetId") != 0
diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql
index ebae46f65b..c45d90a7a3 100644
--- a/server/src/infra/sql/search.repository.sql
+++ b/server/src/infra/sql/search.repository.sql
@@ -79,7 +79,10 @@ FROM
AND "exifInfo"."lensModel" = $2
AND 1 = 1
AND 1 = 1
- AND "asset"."isFavorite" = $3
+ AND (
+ "asset"."isFavorite" = $3
+ AND "asset"."isArchived" = $4
+ )
AND (
"stack"."primaryAssetId" = "asset"."id"
OR "asset"."stackId" IS NULL
@@ -177,16 +180,19 @@ WHERE
AND "exifInfo"."lensModel" = $2
AND 1 = 1
AND 1 = 1
- AND "asset"."isFavorite" = $3
+ AND (
+ "asset"."isFavorite" = $3
+ AND "asset"."isArchived" = $4
+ )
AND (
"stack"."primaryAssetId" = "asset"."id"
OR "asset"."stackId" IS NULL
)
- AND "asset"."ownerId" IN ($4)
+ AND "asset"."ownerId" IN ($5)
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
- "search"."embedding" <= > $5 ASC
+ "search"."embedding" <= > $6 ASC
LIMIT
101
COMMIT
@@ -232,3 +238,37 @@ FROM
WHERE
res.distance <= $3
COMMIT
+
+-- SearchRepository.searchPlaces
+SELECT
+ "geoplaces"."id" AS "geoplaces_id",
+ "geoplaces"."name" AS "geoplaces_name",
+ "geoplaces"."longitude" AS "geoplaces_longitude",
+ "geoplaces"."latitude" AS "geoplaces_latitude",
+ "geoplaces"."countryCode" AS "geoplaces_countryCode",
+ "geoplaces"."admin1Code" AS "geoplaces_admin1Code",
+ "geoplaces"."admin2Code" AS "geoplaces_admin2Code",
+ "geoplaces"."admin1Name" AS "geoplaces_admin1Name",
+ "geoplaces"."admin2Name" AS "geoplaces_admin2Name",
+ "geoplaces"."alternateNames" AS "geoplaces_alternateNames",
+ "geoplaces"."modificationDate" AS "geoplaces_modificationDate"
+FROM
+ "geodata_places" "geoplaces"
+WHERE
+ f_unaccent (name) %>> f_unaccent ($1)
+ OR f_unaccent ("admin2Name") %>> f_unaccent ($1)
+ OR f_unaccent ("admin1Name") %>> f_unaccent ($1)
+ OR f_unaccent ("alternateNames") %>> f_unaccent ($1)
+ORDER BY
+ COALESCE(f_unaccent (name) <->>> f_unaccent ($1), 0) + COALESCE(
+ f_unaccent ("admin2Name") <->>> f_unaccent ($1),
+ 0
+ ) + COALESCE(
+ f_unaccent ("admin1Name") <->>> f_unaccent ($1),
+ 0
+ ) + COALESCE(
+ f_unaccent ("alternateNames") <->>> f_unaccent ($1),
+ 0
+ ) ASC
+LIMIT
+ 20
diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts
index e0bdab269a..06a2cb76d0 100644
--- a/server/test/repositories/search.repository.mock.ts
+++ b/server/test/repositories/search.repository.mock.ts
@@ -7,5 +7,6 @@ export const newSearchRepositoryMock = (): jest.Mocked => {
searchSmart: jest.fn(),
searchFaces: jest.fn(),
upsert: jest.fn(),
+ searchPlaces: jest.fn(),
};
};
diff --git a/web/package-lock.json b/web/package-lock.json
index 654c7154cb..78e5caf7c5 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich-web",
- "version": "1.1.0",
+ "version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
@@ -32,7 +32,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
- "@sveltejs/kit": "^2.5.0",
+ "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
@@ -1859,9 +1859,9 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz",
- "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
+ "integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -8733,9 +8733,9 @@
}
},
"node_modules/vite": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
- "integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
+ "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
diff --git a/web/package.json b/web/package.json
index 361849eb73..2b53d06451 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-web",
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -27,7 +27,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
- "@sveltejs/kit": "^2.5.0",
+ "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
diff --git a/web/src/hooks.client.ts b/web/src/hooks.client.ts
index 1e29371fa9..802e9a7122 100644
--- a/web/src/hooks.client.ts
+++ b/web/src/hooks.client.ts
@@ -1,34 +1,22 @@
+import { isHttpError } from '@immich/sdk';
import type { HandleClientError } from '@sveltejs/kit';
-import type { AxiosError, AxiosResponse } from 'axios';
const LOG_PREFIX = '[hooks.client.ts]';
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
const parseError = (error: unknown) => {
- const httpError = error as AxiosError;
- const request = httpError?.request as Request & { path: string };
- const response = httpError?.response as AxiosResponse<{
- message: string;
- statusCode: number;
- error: string;
- }>;
+ const httpError = isHttpError(error) ? error : undefined;
+ const statusCode = httpError?.status || httpError?.data?.statusCode || 500;
+ const message = httpError?.data?.message || (httpError?.data && String(httpError.data)) || httpError?.message;
- let code = response?.data?.statusCode || response?.status || httpError.code || '500';
- if (response) {
- code += ` - ${response.data?.error || response.statusText}`;
- }
-
- if (request && response) {
- console.log({
- status: response.status,
- url: `${request.method} ${request.path}`,
- response: response.data || 'No data',
- });
- }
+ console.log({
+ status: statusCode,
+ response: httpError?.data || 'No data',
+ });
return {
- message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
- code,
+ message: message || DEFAULT_MESSAGE,
+ code: statusCode,
stack: httpError?.stack,
};
};
diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
index cd73b77f44..f4bead5b39 100644
--- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
@@ -14,12 +14,14 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingAccordion from '../setting-accordion.svelte';
- import SettingButtonsRow from '../setting-buttons-row.svelte';
- import SettingCheckboxes from '../setting-checkboxes.svelte';
- import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
- import SettingSelect from '../setting-select.svelte';
- import SettingSwitch from '../setting-switch.svelte';
+ import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
+ import SettingInputField, {
+ SettingInputFieldType,
+ } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+ import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+ import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -90,7 +92,10 @@
]}
name="acodec"
isEdited={config.ffmpeg.targetAudioCodec !== savedConfig.ffmpeg.targetAudioCodec}
- on:select={() => (config.ffmpeg.acceptedAudioCodecs = [config.ffmpeg.targetAudioCodec])}
+ on:select={() =>
+ config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)
+ ? null
+ : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)}
/>
- import SettingButtonsRow from '$lib/components/admin-page/settings/setting-buttons-row.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
+ import SettingInputField, {
+ SettingInputFieldType,
+ } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
diff --git a/web/src/lib/components/admin-page/settings/setting-input-field.svelte b/web/src/lib/components/admin-page/settings/setting-input-field.svelte
deleted file mode 100644
index 3f16cdd431..0000000000
--- a/web/src/lib/components/admin-page/settings/setting-input-field.svelte
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
-
- {#if required}
-
*
- {/if}
-
- {#if isEdited}
-
- Unsaved change
-
- {/if}
-
-
- {#if desc}
-
- {desc}
-
- {:else}
-
- {/if}
-
-
-
diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte
deleted file mode 100644
index 6797423a55..0000000000
--- a/web/src/lib/components/admin-page/settings/setting-switch.svelte
+++ /dev/null
@@ -1,97 +0,0 @@
-
-
-
-
-
-
- {#if isEdited}
-
- Unsaved change
-
- {/if}
-
-
-
{subtitle}
-
-
-
-
-
-
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
index 475c3a65cb..11e07d0029 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
@@ -13,11 +13,13 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingButtonsRow from '../setting-buttons-row.svelte';
- import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
- import SettingSwitch from '../setting-switch.svelte';
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
+ import SettingInputField, {
+ SettingInputFieldType,
+ } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -82,7 +84,26 @@
};
-
+
+
{#await getTemplateOptions() then}
+ import { locale } from '$lib/stores/preferences.store';
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
- import * as luxon from 'luxon';
+ import { DateTime } from 'luxon';
export let options: SystemConfigTemplateStorageOptionDto;
const getLuxonExample = (format: string) => {
- return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(format);
+ return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
};
diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
index bb1f6351be..10c52c1361 100644
--- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
@@ -4,8 +4,8 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingButtonsRow from '../setting-buttons-row.svelte';
- import SettingTextarea from '../setting-textarea.svelte';
+ import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
diff --git a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
index 4c63380fda..8e2936b556 100644
--- a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
@@ -1,13 +1,16 @@
+
+
($isShowDetail = false)}
on:closeViewer={handleCloseViewer}
- on:descriptionFocusIn={disableKeyDownEvent}
- on:descriptionFocusOut={enableKeyDownEvent}
/>
{/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 15d46da80b..38f65e3df7 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -40,6 +40,7 @@
import ChangeLocation from '../shared-components/change-location.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
+ import { NotificationType, notificationController } from '../shared-components/notification/notification';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@@ -101,9 +102,6 @@
const dispatch = createEventDispatcher<{
close: void;
- descriptionFocusIn: void;
- descriptionFocusOut: void;
- click: AlbumResponseDto;
closeViewer: void;
}>();
@@ -139,19 +137,18 @@
showEditFaces = false;
};
- const handleFocusIn = () => {
- dispatch('descriptionFocusIn');
- };
-
const handleFocusOut = async () => {
textArea.blur();
if (description === originalDescription) {
return;
}
originalDescription = description;
- dispatch('descriptionFocusOut');
try {
await updateAsset({ id: asset.id, updateAssetDto: { description } });
+ notificationController.show({
+ type: NotificationType.Info,
+ message: 'Asset description has been updated',
+ });
} catch (error) {
handleError(error, 'Cannot update the description');
}
@@ -220,7 +217,6 @@
class="max-h-[500px]
w-full resize-none overflow-hidden border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary"
placeholder={isOwner ? 'Add a description' : ''}
- on:focusin={handleFocusIn}
on:focusout={handleFocusOut}
on:input={() => autoGrowHeight(textArea)}
bind:value={description}
@@ -447,6 +443,7 @@
{@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal
? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined,
+ locale: $locale,
})
: DateTime.now()}
APPEARS IN
{#each albums as album}
-
- dispatch('click', album)}
- on:keydown={() => dispatch('click', album)}
- >
+
- import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
- import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
+ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+ import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
+ import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
import { slideshowStore } from '$lib/stores/slideshow.store';
+ import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
- import {
- mdiChevronLeft,
- mdiChevronRight,
- mdiClose,
- mdiPause,
- mdiPlay,
- mdiShuffle,
- mdiShuffleDisabled,
- } from '@mdi/js';
- const { slideshowShuffle } = slideshowStore;
- const { restartProgress, stopProgress } = slideshowStore;
+ const { restartProgress, stopProgress, slideshowDelay, showProgressBar } = slideshowStore;
let progressBarStatus: ProgressBarStatus;
let progressBar: ProgressBar;
+ let showSettings = false;
let unsubscribeRestart: () => void;
let unsubscribeStop: () => void;
@@ -54,25 +47,27 @@
- dispatch('close')} title="Exit Slideshow" />
- {#if $slideshowShuffle}
- ($slideshowShuffle = false)} title="Shuffle" />
- {:else}
- ($slideshowShuffle = true)} title="No shuffle" />
- {/if}
+ dispatch('close')} title="Exit Slideshow" />
(progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
/>
- dispatch('prev')} title="Previous" />
- dispatch('next')} title="Next" />
+ dispatch('prev')} title="Previous" />
+ dispatch('next')} title="Next" />
+ (showSettings = !showSettings)} title="Next" />
+{#if showSettings}
+
(showSettings = false)} />
+{/if}
+
dispatch('next')}
- duration={5000}
/>
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index ec511d4192..8ee042a1a6 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -3,7 +3,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
- import { timeToSeconds } from '$lib/utils/time-to-seconds';
+ import { timeToSeconds } from '$lib/utils/date-time';
import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte
new file mode 100644
index 0000000000..e4ec4bcab8
--- /dev/null
+++ b/web/src/lib/components/elements/date-input.svelte
@@ -0,0 +1,24 @@
+
+
+ {
+ updatedValue = e.currentTarget.value;
+
+ // Only update when value is not empty to prevent resetting the input
+ if (updatedValue !== '') {
+ value = updatedValue;
+ }
+ }}
+ on:blur={() => (value = updatedValue)}
+/>
diff --git a/web/src/lib/components/faces-page/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte
similarity index 75%
rename from web/src/lib/components/faces-page/search-bar.svelte
rename to web/src/lib/components/elements/search-bar.svelte
index e1f999dbca..898601d0ad 100644
--- a/web/src/lib/components/faces-page/search-bar.svelte
+++ b/web/src/lib/components/elements/search-bar.svelte
@@ -1,12 +1,14 @@
-
+