1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

Merge branch 'main' of github.com:immich-app/immich into refactor/immich-thumbnail

This commit is contained in:
Alex Tran 2024-02-26 21:26:25 -06:00
commit 4532db552e
238 changed files with 4470 additions and 3278 deletions

View file

@ -128,3 +128,9 @@ If you feel like this is the right cause and the app is something you are seeing
<a href="https://github.com/alextran1502/immich/graphs/contributors"> <a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/> <img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a> </a>
## Star History
<a href="https://star-history.com/#immich-app/immich">
<img src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" alt="Star History Chart" width="100%" />
</a>

24
cli/package-lock.json generated
View file

@ -1325,9 +1325,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@ -5240,9 +5240,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.1.2", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==", "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
@ -6481,9 +6481,9 @@
} }
}, },
"@types/node": { "@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@ -9364,9 +9364,9 @@
} }
}, },
"vite": { "vite": {
"version": "5.1.2", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==", "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true, "dev": true,
"requires": { "requires": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",

View file

@ -17,7 +17,7 @@ x-server-build: &server-common
services: services:
immich-server: immich-server:
container_name: immich_server container_name: immich_server
command: [ "./start-server.sh" ] command: [ "start.sh", "immich" ]
<<: *server-common <<: *server-common
ports: ports:
- 2283:3001 - 2283:3001
@ -27,7 +27,7 @@ services:
immich-microservices: immich-microservices:
container_name: immich_microservices container_name: immich_microservices
command: [ "./start-microservices.sh" ] command: [ "start.sh", "microservices" ]
<<: *server-common <<: *server-common
# extends: # extends:
# file: hwaccel.transcoding.yml # file: hwaccel.transcoding.yml

View file

@ -44,22 +44,13 @@ Below is an example config for Apache2 site configuration.
``` ```
<VirtualHost *:80> <VirtualHost *:80>
ServerName <snip> ServerName <snip>
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
<Location />
ProxyPass http://localhost:2283/
ProxyPassReverse http://localhost:2283/
</Location>
</VirtualHost> </VirtualHost>
``` ```
**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.

View file

@ -4,6 +4,7 @@ name: immich-e2e
x-server-build: &server-common x-server-build: &server-common
image: immich-server:latest image: immich-server:latest
container_name: immich-e2e-server
build: build:
context: ../ context: ../
dockerfile: server/Dockerfile dockerfile: server/Dockerfile

89
e2e/package-lock.json generated
View file

@ -23,9 +23,13 @@
} }
}, },
"../cli": { "../cli": {
"name": "@immich/cli",
"version": "2.0.8", "version": "2.0.8",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": {
"lodash-es": "^4.17.21"
},
"bin": { "bin": {
"immich": "dist/index.js" "immich": "dist/index.js"
}, },
@ -34,6 +38,7 @@
"@testcontainers/postgresql": "^10.7.1", "@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
@ -801,9 +806,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@ -899,9 +904,9 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz",
"integrity": "sha512-e5Y5uK5NNoQMQaNitGQQjo9FoA5ZNcu7Bn6pH+dxUf48u6po1cX38kFBYUHZ9GNVkF4JLbncE0WeWwTw+nLrxg==", "integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.1", "@ampproject/remapping": "^2.2.1",
@ -922,17 +927,17 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"vitest": "1.3.0" "vitest": "1.3.1"
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
"integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==", "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/spy": "1.3.0", "@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"chai": "^4.3.10" "chai": "^4.3.10"
}, },
"funding": { "funding": {
@ -940,12 +945,12 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
"integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==", "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"p-limit": "^5.0.0", "p-limit": "^5.0.0",
"pathe": "^1.1.1" "pathe": "^1.1.1"
}, },
@ -954,9 +959,9 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
"integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==", "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"magic-string": "^0.30.5", "magic-string": "^0.30.5",
@ -968,9 +973,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
"integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==", "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"tinyspy": "^2.2.0" "tinyspy": "^2.2.0"
@ -980,9 +985,9 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
"integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==", "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"diff-sequences": "^29.6.3", "diff-sequences": "^29.6.3",
@ -2546,9 +2551,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.1.3", "version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
"integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", "integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
@ -2601,9 +2606,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
"integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==", "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
@ -2637,16 +2642,16 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
"integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==", "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.3.0", "@vitest/expect": "1.3.1",
"@vitest/runner": "1.3.0", "@vitest/runner": "1.3.1",
"@vitest/snapshot": "1.3.0", "@vitest/snapshot": "1.3.1",
"@vitest/spy": "1.3.0", "@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"acorn-walk": "^8.3.2", "acorn-walk": "^8.3.2",
"chai": "^4.3.10", "chai": "^4.3.10",
"debug": "^4.3.4", "debug": "^4.3.4",
@ -2660,7 +2665,7 @@
"tinybench": "^2.5.1", "tinybench": "^2.5.1",
"tinypool": "^0.8.2", "tinypool": "^0.8.2",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-node": "1.3.0", "vite-node": "1.3.1",
"why-is-node-running": "^2.2.2" "why-is-node-running": "^2.2.2"
}, },
"bin": { "bin": {
@ -2675,8 +2680,8 @@
"peerDependencies": { "peerDependencies": {
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0", "@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.3.0", "@vitest/browser": "1.3.1",
"@vitest/ui": "1.3.0", "@vitest/ui": "1.3.1",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },

View file

@ -1,79 +1,94 @@
import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain'; import {
import { ActivityController } from '@app/immich'; ActivityCreateDto,
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; AlbumResponseDto,
import { ActivityEntity } from '@app/infra/entities'; AssetResponseDto,
import { errorStub, userDto, uuidStub } from '@test/fixtures'; 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 request from 'supertest';
import { api } from '../../client'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { testApp } from '../utils';
describe(`${ActivityController.name} (e2e)`, () => { describe('/activity', () => {
let server: any;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
let nonOwner: LoginResponseDto; 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 () => { beforeAll(async () => {
server = (await testApp.create()).getHttpServer(); apiUtils.setup();
await testApp.reset(); await dbUtils.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
asset = await api.assetApi.upload(server, admin.accessToken, 'example');
await api.userApi.create(server, admin.accessToken, userDto.user1); admin = await apiUtils.adminSetup();
nonOwner = await api.authApi.login(server, userDto.user1); nonOwner = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
asset = await apiUtils.createAsset(admin.accessToken);
album = await api.albumApi.create(server, admin.accessToken, { album = await createAlbum(
albumName: 'Album 1', {
assetIds: [asset.id], createAlbumDto: {
sharedWithUserIds: [nonOwner.userId], albumName: 'Album 1',
}); assetIds: [asset.id],
}); sharedWithUserIds: [nonOwner.userId],
},
afterAll(async () => { },
await testApp.teardown(); { headers: asBearerAuth(admin.accessToken) }
);
}); });
beforeEach(async () => { beforeEach(async () => {
await testApp.reset({ entities: [ActivityEntity] }); await dbUtils.reset(['activity']);
}); });
describe('GET /activity', () => { describe('GET /activity', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require an albumId', async () => { it('should require an albumId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400); 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 () => { it('should reject an invalid albumId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: uuidStub.invalid }) .query({ albumId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400); 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 () => { it('should reject an invalid assetId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid }) .query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400); 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 () => { it('should start off empty', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id }) .query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -82,22 +97,22 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should filter by album id', async () => { it('should filter by album id', async () => {
const album2 = await api.albumApi.create(server, admin.accessToken, { const album2 = await createAlbum(
albumName: 'Album 2', {
assetIds: [asset.id], createAlbumDto: {
}); albumName: 'Album 2',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) }
);
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { createActivity({ albumId: album.id, type: ReactionType.Like }),
albumId: album.id, createActivity({ albumId: album2.id, type: ReactionType.Like }),
type: ReactionType.LIKE,
}),
api.activityApi.create(server, admin.accessToken, {
albumId: album2.id,
type: ReactionType.LIKE,
}),
]); ]);
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id }) .query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -108,15 +123,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by type=comment', async () => { it('should filter by type=comment', async () => {
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: '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') .get('/activity')
.query({ albumId: album.id, type: 'comment' }) .query({ albumId: album.id, type: 'comment' })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -127,15 +142,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by type=like', async () => { it('should filter by type=like', async () => {
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), createActivity({ albumId: album.id, type: ReactionType.Like }),
api.activityApi.create(server, admin.accessToken, { createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'comment', comment: 'comment',
}), }),
]); ]);
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id, type: 'like' }) .query({ albumId: album.id, type: 'like' })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -146,18 +161,18 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by userId', async () => { it('should filter by userId', async () => {
const [reaction] = await Promise.all([ 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') .get('/activity')
.query({ albumId: album.id, userId: uuidStub.notFound }) .query({ albumId: album.id, userId: uuidDto.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(response1.status).toEqual(200); expect(response1.status).toEqual(200);
expect(response1.body.length).toBe(0); expect(response1.body.length).toBe(0);
const response2 = await request(server) const response2 = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id, userId: admin.userId }) .query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -169,15 +184,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by assetId', async () => { it('should filter by assetId', async () => {
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { createActivity({
albumId: album.id, albumId: album.id,
assetId: asset.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') .get('/activity')
.query({ albumId: album.id, assetId: asset.id }) .query({ albumId: album.id, assetId: asset.id })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -189,34 +204,45 @@ describe(`${ActivityController.name} (e2e)`, () => {
describe('POST /activity', () => { describe('POST /activity', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require an albumId', async () => { it('should require an albumId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidStub.invalid }); .send({ albumId: uuidDto.invalid });
expect(status).toEqual(400); 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 () => { it('should require a comment when type is comment', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .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(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 () => { it('should add a comment to an album', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .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(status).toEqual(201);
expect(body).toEqual({ expect(body).toEqual({
id: expect.any(String), id: expect.any(String),
@ -229,7 +255,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should add a like to an album', async () => { it('should add a like to an album', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' }); .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 () => { it('should return a 200 for a duplicate like on the album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const [reaction] = await Promise.all([
albumId: album.id, createActivity({ albumId: album.id, type: ReactionType.Like }),
type: ReactionType.LIKE, ]);
});
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' }); .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 () => { it('should not confuse an album like with an asset like', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const [reaction] = await Promise.all([
albumId: album.id, createActivity({
assetId: asset.id, albumId: album.id,
type: ReactionType.LIKE, assetId: asset.id,
}); type: ReactionType.Like,
const { status, body } = await request(server) }),
]);
const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' }); .send({ albumId: album.id, type: 'like' });
@ -272,10 +300,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should add a comment to an asset', async () => { it('should add a comment to an asset', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .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(status).toEqual(201);
expect(body).toEqual({ expect(body).toEqual({
id: expect.any(String), id: expect.any(String),
@ -288,7 +321,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should add a like to an asset', async () => { it('should add a like to an asset', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' }); .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 () => { it('should return a 200 for a duplicate like on an asset', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const [reaction] = await Promise.all([
albumId: album.id, createActivity({
assetId: asset.id, albumId: album.id,
type: ReactionType.LIKE, assetId: asset.id,
}); type: ReactionType.Like,
const { status, body } = await request(server) }),
]);
const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' }); .send({ albumId: album.id, assetId: asset.id, type: 'like' });
@ -320,50 +356,52 @@ describe(`${ActivityController.name} (e2e)`, () => {
describe('DELETE /activity/:id', () => { describe('DELETE /activity/:id', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/activity/${uuidStub.invalid}`) .delete(`/activity/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); 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 () => { 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, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'This is a test comment', comment: 'This is a test comment',
}); });
const { status } = await request(server) const { status } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204); expect(status).toEqual(204);
}); });
it('should remove a like from an album', async () => { 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, albumId: album.id,
type: ReactionType.LIKE, type: ReactionType.Like,
}); });
const { status } = await request(server) const { status } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204); expect(status).toEqual(204);
}); });
it('should let the owner remove a comment by another user', async () => { 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, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'This is a test comment', comment: 'This is a test comment',
}); });
const { status } = await request(server) const { status } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .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 () => { 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, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'This is a test comment', comment: 'This is a test comment',
}); });
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`); .set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400); 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 () => { it('should let a non-owner remove their own comment', async () => {
const reaction = await api.activityApi.create(server, nonOwner.accessToken, { const reaction = await createActivity(
albumId: album.id, {
type: ReactionType.COMMENT, albumId: album.id,
comment: 'This is a test comment', 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}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`); .set('Authorization', `Bearer ${nonOwner.accessToken}`);

View file

@ -1,11 +1,15 @@
import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; import {
import { AlbumController } from '@app/immich'; AlbumResponseDto,
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; AssetResponseDto,
import { SharedLinkType } from '@app/infra/entities'; LoginResponseDto,
import { errorStub, userDto, uuidStub } from '@test/fixtures'; 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 request from 'supertest';
import { api } from '../../client'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { testApp } from '../utils';
const user1SharedUser = 'user1SharedUser'; const user1SharedUser = 'user1SharedUser';
const user1SharedLink = 'user1SharedLink'; const user1SharedLink = 'user1SharedLink';
@ -14,193 +18,327 @@ const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink'; const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared'; const user2NotShared = 'user2NotShared';
describe(`${AlbumController.name} (e2e)`, () => { describe('/album', () => {
let server: any;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user1Asset: AssetFileUploadResponseDto; let user1Asset1: AssetResponseDto;
let user1Asset2: AssetResponseDto;
let user1Albums: AlbumResponseDto[]; let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto; let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[]; let user2Albums: AlbumResponseDto[];
let user3: LoginResponseDto; // deleted
beforeAll(async () => { beforeAll(async () => {
server = (await testApp.create()).getHttpServer(); apiUtils.setup();
}); await dbUtils.reset();
afterAll(async () => { admin = await apiUtils.adminSetup();
await testApp.teardown();
});
beforeEach(async () => { [user1, user2, user3] = await Promise.all([
await testApp.reset(); apiUtils.userSetup(admin.accessToken, createUserDto.user1),
await api.authApi.adminSignUp(server); apiUtils.userSetup(admin.accessToken, createUserDto.user2),
admin = await api.authApi.adminLogin(server); apiUtils.userSetup(admin.accessToken, createUserDto.user3),
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]); ]);
[user1, user2] = await Promise.all([ [user1Asset1, user1Asset2] = await Promise.all([
api.authApi.login(server, userDto.user1), apiUtils.createAsset(user1.accessToken),
api.authApi.login(server, userDto.user2), apiUtils.createAsset(user1.accessToken),
]); ]);
user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
const albums = await Promise.all([ const albums = await Promise.all([
// user 1 // user 1
api.albumApi.create(server, user1.accessToken, { apiUtils.createAlbum(user1.accessToken, {
albumName: user1SharedUser, albumName: user1SharedUser,
sharedWithUserIds: [user2.userId], 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 // user 2
api.albumApi.create(server, user2.accessToken, { apiUtils.createAlbum(user2.accessToken, {
albumName: user2SharedUser, albumName: user2SharedUser,
sharedWithUserIds: [user1.userId], 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); user1Albums = albums.slice(0, 3);
user2Albums = albums.slice(3); user2Albums = albums.slice(3, 6);
await Promise.all([ await Promise.all([
// add shared link to user1SharedLink album // add shared link to user1SharedLink album
api.sharedLinkApi.create(server, user1.accessToken, { apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.ALBUM, type: SharedLinkType.Album,
albumId: user1Albums[1].id, albumId: user1Albums[1].id,
}), }),
// add shared link to user2SharedLink album // add shared link to user2SharedLink album
api.sharedLinkApi.create(server, user2.accessToken, { apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.ALBUM, type: SharedLinkType.Album,
albumId: user2Albums[1].id, albumId: user2Albums[1].id,
}), }),
]); ]);
await deleteUser(
{ id: user3.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
}); });
describe('GET /album', () => { describe('GET /album', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should reject an invalid shared param', async () => { it('should reject an invalid shared param', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/album?shared=invalid') .get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400); 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 () => { it('should reject an invalid assetId param', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/album?assetId=invalid') .get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400); 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 () => { 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(app)
const { status, body } = await request(server)
.get('/album?shared=true') .get('/album?shared=true')
.set('Authorization', `Bearer ${user2.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(3);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ 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 () => { 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(status).toBe(200);
expect(body).toHaveLength(3); expect(body).toHaveLength(3);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), expect.objectContaining({
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), ownerId: user1.userId,
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), 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 () => { 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') .get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(3); expect(body).toHaveLength(3);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), expect.objectContaining({
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), ownerId: user1.userId,
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }), 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 () => { 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') .get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(1);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ 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 () => { it('should return the album collection filtered by assetId', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example2'); const { status, body } = await request(app)
await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] }); .get(`/album?assetId=${user1Asset2.id}`)
const { status, body } = await request(server)
.get(`/album?assetId=${asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(1);
}); });
it('should return the album collection filtered by assetId and ignores shared=true', async () => { it('should return the album collection filtered by assetId and ignores shared=true', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get(`/album?shared=true&assetId=${user1Asset.id}`) .get(`/album?shared=true&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(4); expect(body).toHaveLength(4);
}); });
it('should return the album collection filtered by assetId and ignores shared=false', async () => { it('should return the album collection filtered by assetId and ignores shared=false', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get(`/album?shared=false&assetId=${user1Asset.id}`) .get(`/album?shared=false&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(4); 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', () => { describe('POST /album', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should create an album', async () => { 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({ expect(body).toEqual({
id: expect.any(String), id: expect.any(String),
createdAt: 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', () => { describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => { 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(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 () => { it('should be able to add own asset to own album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); const asset = await apiUtils.createAsset(user1.accessToken);
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/assets`) .put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] }); .send({ ids: [asset.id] });
expect(status).toBe(200); 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 () => { it('should be able to add own asset to shared album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); const asset = await apiUtils.createAsset(user1.accessToken);
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${user2Albums[0].id}/assets`) .put(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] }); .send({ ids: [asset.id] });
expect(status).toBe(200); 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', () => { describe('PATCH /album/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/album/${uuidStub.notFound}`) .patch(`/album/${uuidDto.notFound}`)
.send({ albumName: 'New album name' }); .send({ albumName: 'New album name' });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should update an album', async () => { it('should update an album', async () => {
const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); const album = await apiUtils.createAlbum(user1.accessToken, {
const { status, body } = await request(server) albumName: 'New album',
});
const { status, body } = await request(app)
.patch(`/album/${album.id}`) .patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ .send({
@ -345,52 +426,68 @@ describe(`${AlbumController.name} (e2e)`, () => {
describe('DELETE /album/:id/assets', () => { describe('DELETE /album/:id/assets', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`) .delete(`/album/${user1Albums[0].id}/assets`)
.send({ ids: [user1Asset.id] }); .send({ ids: [user1Asset1.id] });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.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 })]);
}); });
it('should not be able to remove foreign asset from own album', async () => { 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`) .delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`) .set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] }); .send({ ids: [user1Asset1.id] });
expect(status).toBe(200); 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 () => { 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`) .delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`) .set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] }); .send({ ids: [user1Asset1.id] });
expect(status).toBe(200); 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; let album: AlbumResponseDto;
beforeEach(async () => { 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 () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/users`) .put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] }); .send({ sharedUserIds: [] });
expect(status).toBe(401); 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 () => { 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`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ sharedUserIds: [user2.userId] });
expect(status).toBe(200); 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 () => { 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`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user1.userId] }); .send({ sharedUserIds: [user1.userId] });
expect(status).toBe(400); 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 () => { it('should not be able to add existing user to shared album', async () => {
await request(server) await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ sharedUserIds: [user2.userId] });
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ sharedUserIds: [user2.userId] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('User already added')); expect(body).toEqual(errorDto.badRequest('User already added'));
}); });
}); });
}); });

View file

@ -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);
});
});
});

View file

@ -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 });
});
});
});

View file

@ -1,21 +1,21 @@
import { import {
AlbumResponseDto, AlbumResponseDto,
AssetResponseDto, AssetResponseDto,
IAssetRepository,
LoginResponseDto, LoginResponseDto,
SharedLinkCreateDto,
SharedLinkResponseDto, SharedLinkResponseDto,
} from '@app/domain'; SharedLinkType,
import { SharedLinkController } from '@app/immich'; createSharedLink as create,
import { SharedLinkType } from '@app/infra/entities'; createAlbum,
import { INestApplication } from '@nestjs/common'; deleteUser,
import { errorStub, userDto, uuidStub } from '@test/fixtures'; } from '@immich/sdk';
import { DateTime } from 'luxon'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { api } from '../../client'; import { beforeAll, describe, expect, it } from 'vitest';
import { testApp } from '../utils';
describe(`${SharedLinkController.name} (e2e)`, () => { describe('/shared-link', () => {
let server: any;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset1: AssetResponseDto; let asset1: AssetResponseDto;
let asset2: AssetResponseDto; let asset2: AssetResponseDto;
@ -30,97 +30,96 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
let linkWithAssets: SharedLinkResponseDto; let linkWithAssets: SharedLinkResponseDto;
let linkWithMetadata: SharedLinkResponseDto; let linkWithMetadata: SharedLinkResponseDto;
let linkWithoutMetadata: SharedLinkResponseDto; let linkWithoutMetadata: SharedLinkResponseDto;
let app: INestApplication<any>;
beforeAll(async () => { beforeAll(async () => {
app = await testApp.create(); apiUtils.setup();
server = app.getHttpServer(); await dbUtils.reset();
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
await testApp.reset(); admin = await apiUtils.adminSetup();
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] = await Promise.all([ [user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1), apiUtils.userSetup(admin.accessToken, createUserDto.user1),
api.authApi.login(server, userDto.user2), apiUtils.userSetup(admin.accessToken, createUserDto.user2),
]); ]);
[asset1, asset2] = await Promise.all([ [asset1, asset2] = await Promise.all([
api.assetApi.create(server, user1.accessToken), apiUtils.createAsset(user1.accessToken),
api.assetApi.create(server, 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([ [album, deletedAlbum, metadataAlbum] = await Promise.all([
api.albumApi.create(server, user1.accessToken, { albumName: 'album' }), createAlbum(
api.albumApi.create(server, user2.accessToken, { albumName: 'deleted album' }), { createAlbumDto: { albumName: 'album' } },
api.albumApi.create(server, user1.accessToken, { albumName: 'metadata album', assetIds: [asset1.id] }), { 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([ linkWithDeletedAlbum,
api.sharedLinkApi.create(server, user2.accessToken, { linkWithAlbum,
type: SharedLinkType.ALBUM, linkWithAssets,
albumId: deletedAlbum.id, linkWithPassword,
}), linkWithMetadata,
api.sharedLinkApi.create(server, user1.accessToken, { linkWithoutMetadata,
type: SharedLinkType.ALBUM, ] = await Promise.all([
albumId: album.id, apiUtils.createSharedLink(user2.accessToken, {
}), type: SharedLinkType.Album,
api.sharedLinkApi.create(server, user1.accessToken, { albumId: deletedAlbum.id,
type: SharedLinkType.INDIVIDUAL, }),
assetIds: [asset1.id], apiUtils.createSharedLink(user1.accessToken, {
}), type: SharedLinkType.Album,
api.sharedLinkApi.create(server, user1.accessToken, { albumId: album.id,
type: SharedLinkType.ALBUM, }),
albumId: album.id, apiUtils.createSharedLink(user1.accessToken, {
password: 'foo', type: SharedLinkType.Individual,
}), assetIds: [asset1.id],
api.sharedLinkApi.create(server, user1.accessToken, { }),
type: SharedLinkType.ALBUM, apiUtils.createSharedLink(user1.accessToken, {
albumId: metadataAlbum.id, type: SharedLinkType.Album,
showMetadata: true, albumId: album.id,
}), password: 'foo',
api.sharedLinkApi.create(server, user1.accessToken, { }),
type: SharedLinkType.ALBUM, apiUtils.createSharedLink(user1.accessToken, {
albumId: metadataAlbum.id, type: SharedLinkType.Album,
showMetadata: false, 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); await deleteUser(
}); { id: user2.userId },
{ headers: asBearerAuth(admin.accessToken) }
afterAll(async () => { );
await testApp.teardown();
}); });
describe('GET /shared-link', () => { describe('GET /shared-link', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should get all shared links created by user', async () => { 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') .get('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
@ -133,12 +132,12 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
expect.objectContaining({ id: linkWithPassword.id }), expect.objectContaining({ id: linkWithPassword.id }),
expect.objectContaining({ id: linkWithMetadata.id }), expect.objectContaining({ id: linkWithMetadata.id }),
expect.objectContaining({ id: linkWithoutMetadata.id }), expect.objectContaining({ id: linkWithoutMetadata.id }),
]), ])
); );
}); });
it('should not get shared links created by other users', async () => { 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') .get('/shared-link')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -149,7 +148,7 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('GET /shared-link/me', () => { describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => { it('should not require admin authentication', async () => {
const { status } = await request(server) const { status } = await request(app)
.get('/shared-link/me') .get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -157,52 +156,66 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
}); });
it('should get data for correct shared link', async () => { 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(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
expect.objectContaining({ expect.objectContaining({
album, album,
userId: user1.userId, userId: user1.userId,
type: SharedLinkType.ALBUM, type: SharedLinkType.Album,
}), })
); );
}); });
it('should return unauthorized for incorrect shared link', async () => { 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') .get('/shared-link/me')
.query({ key: linkWithAlbum.key + 'foo' }); .query({ key: linkWithAlbum.key + 'foo' });
expect(status).toBe(401); 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 () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey); expect(body).toEqual(errorDto.invalidShareKey);
}); });
it('should return unauthorized for password protected link', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.invalidSharePassword); expect(body).toEqual(errorDto.invalidSharePassword);
}); });
it('should get data for correct password protected link', async () => { 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') .get('/shared-link/me')
.query({ key: linkWithPassword.key, password: 'foo' }); .query({ key: linkWithPassword.key, password: 'foo' });
expect(status).toBe(200); 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 () => { 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(status).toBe(200);
expect(body.assets).toHaveLength(1); expect(body.assets).toHaveLength(1);
@ -211,22 +224,16 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
originalFileName: 'example', originalFileName: 'example',
localDateTime: expect.any(String), localDateTime: expect.any(String),
fileCreatedAt: expect.any(String), fileCreatedAt: expect.any(String),
exifInfo: expect.objectContaining({ exifInfo: expect.any(Object),
longitude: -108.400968333333, })
latitude: 39.115,
orientation: '1',
dateTimeOriginal: expect.any(String),
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
}),
}),
); );
expect(body.album).toBeDefined(); expect(body.album).toBeDefined();
}); });
it('should not return metadata for album shared link without metadata', async () => { 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(status).toBe(200);
expect(body.assets).toHaveLength(1); expect(body.assets).toHaveLength(1);
@ -242,127 +249,150 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('GET /shared-link/:id', () => { describe('GET /shared-link/:id', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should get shared link by id', async () => { 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}`) .get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); 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 () => { 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}`) .get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); 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', () => { describe('POST /shared-link', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound }); .send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
expect(status).toBe(401); 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 () => { 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') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
it('should require an asset/album id', async () => { it('should require an asset/album id', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM }); .send({ type: SharedLinkType.Album });
expect(status).toBe(400); 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 () => { it('should require a valid asset id', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound }); .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
expect(status).toBe(400); 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 () => { it('should create a shared link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM, albumId: album.id }); .send({ type: SharedLinkType.Album, albumId: album.id });
expect(status).toBe(201); 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', () => { describe('PATCH /shared-link/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/shared-link/${linkWithAlbum.id}`) .patch(`/shared-link/${linkWithAlbum.id}`)
.send({ description: 'foo' }); .send({ description: 'foo' });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should fail if invalid link', async () => { it('should fail if invalid link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/shared-link/${uuidStub.notFound}`) .patch(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' }); .send({ description: 'foo' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
it('should update shared link', async () => { it('should update shared link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/shared-link/${linkWithAlbum.id}`) .patch(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' }); .send({ description: 'foo' });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( 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', () => { describe('PUT /shared-link/:id/assets', () => {
it('should not add assets to shared link (album)', async () => { 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`) .put(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
expect(status).toBe(400); 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 () => { 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`) .put(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
@ -374,17 +404,17 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('DELETE /shared-link/:id/assets', () => { describe('DELETE /shared-link/:id/assets', () => {
it('should not remove assets from a shared link (album)', async () => { 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`) .delete(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
expect(status).toBe(400); 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 () => { 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`) .delete(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
@ -396,23 +426,25 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('DELETE /shared-link/:id', () => { describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should fail if invalid link', async () => { it('should fail if invalid link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/shared-link/${uuidStub.notFound}`) .delete(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
it('should delete a shared link', async () => { it('should delete a shared link', async () => {
const { status } = await request(server) const { status } = await request(app)
.delete(`/shared-link/${linkWithAlbum.id}`) .delete(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);

View file

@ -1,26 +1,31 @@
import { import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk';
LoginResponseDto,
UserResponseDto,
createUser,
deleteUser,
getUserById,
} from '@immich/sdk';
import { createUserDto, userDto } from 'src/fixtures'; import { createUserDto, userDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
describe('/server-info', () => { describe('/server-info', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let deletedUser: LoginResponseDto;
let userToDelete: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => { beforeAll(async () => {
apiUtils.setup(); apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset(); await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false }); 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', () => { describe('GET /user', () => {
@ -30,60 +35,54 @@ describe('/server-info', () => {
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should start with the admin', async () => { it('should get users', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/user') .get('/user')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200); expect(status).toEqual(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(4);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); 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 () => { 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) const { status, body } = await request(app)
.get(`/user`) .get(`/user`)
.query({ isAll: true }) .query({ isAll: true })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(3);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); 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 () => { 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) const { status, body } = await request(app)
.get(`/user`) .get(`/user`)
.query({ isAll: false }) .query({ isAll: false })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(2); expect(body).toHaveLength(4);
expect(body[0]).toMatchObject({ expect(body).toEqual(
id: user1.userId, expect.arrayContaining([
email: 'user1@immich.cloud', expect.objectContaining({ email: 'admin@immich.cloud' }),
deletedAt: expect.any(String), expect.objectContaining({ email: 'user1@immich.cloud' }),
}); expect.objectContaining({ email: 'user2@immich.cloud' }),
expect(body[1]).toMatchObject({ expect.objectContaining({ email: 'user3@immich.cloud' }),
id: admin.userId, ])
email: 'admin@immich.cloud', );
});
}); });
}); });
@ -149,13 +148,13 @@ describe('/server-info', () => {
.post(`/user`) .post(`/user`)
.send({ .send({
isAdmin: true, isAdmin: true,
email: 'user1@immich.cloud', email: 'user4@immich.cloud',
password: 'Password123', password: 'password123',
name: 'Immich', name: 'Immich',
}) })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({ expect(body).toMatchObject({
email: 'user1@immich.cloud', email: 'user4@immich.cloud',
isAdmin: false, isAdmin: false,
shouldChangePassword: true, shouldChangePassword: true,
}); });
@ -181,18 +180,9 @@ describe('/server-info', () => {
}); });
describe('DELETE /user/:id', () => { describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await createUser(
{ createUserDto: createUserDto.user1 },
{ headers: asBearerAuth(admin.accessToken) }
);
});
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).delete( const { status, body } = await request(app).delete(
`/user/${userToDelete.id}` `/user/${userToDelete.userId}`
); );
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@ -200,12 +190,12 @@ describe('/server-info', () => {
it('should delete user', async () => { it('should delete user', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/user/${userToDelete.id}`) .delete(`/user/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toMatchObject({
...userToDelete, id: userToDelete.userId,
updatedAt: expect.any(String), updatedAt: expect.any(String),
deletedAt: 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 () => { 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) const { status, body } = await request(app)
.put(`/user`) .put(`/user`)
.send({ isAdmin: true, id: user.userId }) .send({ isAdmin: true, id: nonAdmin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);

View file

@ -1,24 +1,33 @@
import { import {
AssetResponseDto, AssetResponseDto,
CreateAlbumDto,
CreateAssetDto, CreateAssetDto,
CreateUserDto, CreateUserDto,
LoginResponseDto, PersonUpdateDto,
SharedLinkCreateDto,
createAlbum,
createApiKey, createApiKey,
createPerson,
createSharedLink,
createUser, createUser,
defaults, defaults,
login, login,
setAdminOnboarding, setAdminOnboarding,
signUpAdmin, signUpAdmin,
updatePerson,
} from '@immich/sdk'; } from '@immich/sdk';
import { BrowserContext } from '@playwright/test'; import { BrowserContext } from '@playwright/test';
import { spawn } from 'child_process'; import { exec, spawn } from 'child_process';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises'; import { access } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { promisify } from 'node:util';
import pg from 'pg'; import pg from 'pg';
import { loginDto, signupDto } from 'src/fixtures'; import { loginDto, signupDto } from 'src/fixtures';
import request from 'supertest'; import request from 'supertest';
const execPromise = promisify(exec);
export const app = 'http://127.0.0.1:2283/api'; export const app = 'http://127.0.0.1:2283/api';
const directoryExists = (directory: string) => const directoryExists = (directory: string) =>
@ -29,6 +38,9 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets // TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/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`))) { if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error( throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing` `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; let client: pg.Client | null = null;
export const dbUtils = { export const fileUtils = {
reset: async () => { 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 { try {
if (!client) { if (!client) {
client = new pg.Client( client = new pg.Client(
@ -54,14 +103,20 @@ export const dbUtils = {
await client.connect(); await client.connect();
} }
for (const table of [ tables = tables || [
'shared_links',
'person',
'albums', 'albums',
'assets', 'assets',
'asset_faces',
'activity',
'api_keys', 'api_keys',
'user_token', 'user_token',
'users', 'users',
'system_metadata', 'system_metadata',
]) { ];
for (const table of tables) {
await client.query(`DELETE FROM ${table} CASCADE;`); await client.query(`DELETE FROM ${table} CASCADE;`);
} }
} catch (error) { } catch (error) {
@ -144,6 +199,11 @@ export const apiUtils = {
{ headers: asBearerAuth(accessToken) } { headers: asBearerAuth(accessToken) }
); );
}, },
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum(
{ createAlbumDto: dto },
{ headers: asBearerAuth(accessToken) }
),
createAsset: async ( createAsset: async (
accessToken: string, accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'> dto?: Omit<CreateAssetDto, 'assetData'>
@ -165,6 +225,20 @@ export const apiUtils = {
return body as AssetResponseDto; 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 = { export const cliUtils = {

View file

@ -1 +0,0 @@
from .ann import Ann, is_available

View file

@ -32,8 +32,7 @@ T = TypeVar("T", covariant=True)
class Newable(Protocol[T]): class Newable(Protocol[T]):
def new(self) -> None: def new(self) -> None: ...
...
class _Singleton(type, Newable[T]): class _Singleton(type, Newable[T]):

View file

@ -1,18 +1,16 @@
from __future__ import annotations from __future__ import annotations
import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from typing import Any from typing import Any
import onnx
import onnxruntime as ort import onnxruntime as ort
from huggingface_hub import snapshot_download 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 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 ..config import get_cache_dir, get_hf_model_name, log, settings
from ..schemas import ModelRuntime, ModelType from ..schemas import ModelRuntime, ModelType
@ -113,63 +111,25 @@ class InferenceModel(ABC):
) )
model_path = onnx_path 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: match model_path.suffix:
case ".armnn": case ".armnn":
session = AnnSession(model_path) session = AnnSession(model_path)
case ".onnx": case ".onnx":
session = ort.InferenceSession( cwd = os.getcwd()
model_path.as_posix(), try:
sess_options=self.sess_options, os.chdir(model_path.parent)
providers=self.providers, session = ort.InferenceSession(
provider_options=self.provider_options, model_path.as_posix(),
) sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
finally:
os.chdir(cwd)
case _: case _:
raise ValueError(f"Unsupported model file type: {model_path.suffix}") raise ValueError(f"Unsupported model file type: {model_path.suffix}")
return session 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 @property
def model_type(self) -> ModelType: def model_type(self) -> ModelType:
return self._model_type return self._model_type

View file

@ -54,9 +54,6 @@ _INSIGHTFACE_MODELS = {
SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"] SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"]
STATIC_INPUT_PROVIDERS = ["OpenVINOExecutionProvider"]
def is_openclip(model_name: str) -> bool: def is_openclip(model_name: str) -> bool:
return clean_name(model_name) in _OPENCLIP_MODELS return clean_name(model_name) in _OPENCLIP_MODELS

View file

@ -1,4 +1,5 @@
import json import json
import os
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from random import randint from random import randint
@ -237,12 +238,12 @@ class TestBase:
mock_model_path.is_file.return_value = True mock_model_path.is_file.return_value = True
mock_model_path.suffix = ".armnn" mock_model_path.suffix = ".armnn"
mock_model_path.with_suffix.return_value = mock_model_path 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 = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_model_path) 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: def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None:
mock_armnn_path = mocker.Mock() mock_armnn_path = mocker.Mock()
@ -256,6 +257,7 @@ class TestBase:
mock_ann = mocker.patch("app.models.base.AnnSession") mock_ann = mocker.patch("app.models.base.AnnSession")
mock_ort = mocker.patch("app.models.base.ort.InferenceSession") mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
mocker.patch("app.models.base.os.chdir")
encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_armnn_path) encoder._make_session(mock_armnn_path)
@ -278,6 +280,26 @@ class TestBase:
mock_ann.assert_not_called() mock_ann.assert_not_called()
mock_ort.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: def test_download(self, mocker: MockerFixture) -> None:
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download") mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")

View file

@ -64,33 +64,33 @@ trio = ["trio (>=0.23)"]
[[package]] [[package]]
name = "black" name = "black"
version = "24.1.1" version = "24.2.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"}, {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"},
{file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"}, {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"},
{file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"}, {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"},
{file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"}, {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"},
{file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"}, {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"},
{file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"}, {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"},
{file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"}, {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"},
{file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"}, {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"},
{file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"}, {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"},
{file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"}, {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"},
{file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"}, {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"},
{file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"}, {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"},
{file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"}, {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"},
{file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"}, {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"},
{file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"}, {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"},
{file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"}, {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"},
{file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"}, {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"},
{file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"}, {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"},
{file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"}, {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"},
{file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"}, {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"},
{file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"}, {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"},
{file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"}, {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"},
] ]
[package.dependencies] [package.dependencies]
@ -2101,61 +2101,61 @@ numpy = [
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.9.13" version = "3.9.14"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ 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.14-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:793f6c9448ab6eb7d4974b4dde3f230345c08ca6c7995330fbceeb43a5c8aa5e"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b812417199eeb169c25f67815cfb66fd8de7ff098bf57d065e8c1943a7ba5c8f"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bc7928d161840096adc956703494b5c0193ede887346f028216cac0af87500"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ccd5bd222e5041069ad9d9868ab59e6dbc53ecde8d8c82b919954fbba43b46b"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58b36f54da759602d8e2f7dad958752d453dfe2c7122767bc7f765e17dc59959"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaaf80957c38e9d3f796f355a80fad945e72cd745e6b64c210e635b7043b673e"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:abcda41ecdc950399c05eff761c3de91485d9a70d8227cb599ad3a66afe93bcc"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60da7316131185d0110a1848e9ad15311e6c8938ee0b5be8cbd7261e1d80ee8f"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df76ecd17b1b3627bddfd689faaf206380a1a38cc9f6c4075bd884eaedcf46c2"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b98cd948372f0eb219bc309dee4633db1278687161e3280d9e693b6076951d2"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d450a8e0656efb5d0fcb062157b918ab02dcca73278975b4ee9ea49e2fcf5bd5"},
{file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3869d65561f10071d3e7f35ae58fd377056f67d7aaed5222f318390c3ad30339"}, {file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:95c03137b0cf66517c8baa65770507a756d3a89489d8ecf864ea92348e1beabe"},
{file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43fd6036b16bb6742d03dae62f7bdf8214d06dea47e4353cde7e2bd1358d186f"}, {file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20837e10835c98973673406d6798e10f821e7744520633811a5a3d809762d8cc"},
{file = "orjson-3.9.13-cp310-none-win32.whl", hash = "sha256:0d3ba9d88e20765335260d7b25547d7c571eee2b698200f97afa7d8c7cd668fc"}, {file = "orjson-3.9.14-cp310-none-win32.whl", hash = "sha256:1f7b6f3ef10ae8e3558abb729873d033dbb5843507c66b1c0767e32502ba96bb"},
{file = "orjson-3.9.13-cp310-none-win_amd64.whl", hash = "sha256:6e47153db080f5e87e8ba638f1a8b18995eede6b0abb93964d58cf11bcea362f"}, {file = "orjson-3.9.14-cp310-none-win_amd64.whl", hash = "sha256:ea890e6dc1711aeec0a33b8520e395c2f3d59ead5b4351a788e06bf95fc7ba81"},
{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.14-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c19009ff37f033c70acd04b636380379499dac2cba27ae7dfc24f304deabbc81"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f37f0cdd026ef777a4336e599d8194c8357fc14760c2a5ddcfdf1965d45504b"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19cdea0664aec0b7f385be84986d4defd3334e9c3c799407686ee1c26f7b8251"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d714595d81efab11b42bccd119977d94b25d12d3a806851ff6bfd286a4bce960"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:135d518f73787ce323b1a5e21fb854fe22258d7a8ae562b81a49d6c7f826f2a3"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9171e8e1a1f221953e38e84ae0abffe8759002fd8968106ee379febbb5358b33"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2cf1d0557c61c75e18cf7d69fb689b77896e95553e212c0cc64cf2087944b84"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ab9dbdec3f13f3ea6f937564ce21651844cfbf2725099f2f490426acf683c23"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7c11667421df2d8b18b021223505dcc3ee51be518d54e4dc49161ac88ac2b87"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:811ac076855e33e931549340288e0761873baf29276ad00f221709933c644330"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eefc41ba42e75ed88bc396d8fe997beb20477f3e7efa000cd7a47eda452fbb2"},
{file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:860d0f5b42d0c0afd73fa4177709f6e1b966ba691fcd72175affa902052a81d6"}, {file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:917311d6a64d1c327c0dfda1e41f3966a7fb72b11ca7aa2e7a68fcccc7db35d9"},
{file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:838b898e8c1f26eb6b8d81b180981273f6f5110c76c22c384979aca854194f1b"}, {file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4dc1c132259b38d12c6587d190cd09cd76e3b5273ce71fe1372437b4cbc65f6f"},
{file = "orjson-3.9.13-cp311-none-win32.whl", hash = "sha256:d3222db9df629ef3c3673124f2e05fb72bc4a320c117e953fec0d69dde82e36d"}, {file = "orjson-3.9.14-cp311-none-win32.whl", hash = "sha256:6f39a10408478f4c05736a74da63727a1ae0e83e3533d07b19443400fe8591ca"},
{file = "orjson-3.9.13-cp311-none-win_amd64.whl", hash = "sha256:978117122ca4cc59b28af5322253017f6c5fc03dbdda78c7f4b94ae984c8dd43"}, {file = "orjson-3.9.14-cp311-none-win_amd64.whl", hash = "sha256:26280a7fcb62d8257f634c16acebc3bec626454f9ab13558bbf7883b9140760e"},
{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.14-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:08e722a8d06b13b67a51f247a24938d1a94b4b3862e40e0eef3b2e98c99cd04c"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd9a2101d04e85086ea6198786a3f016e45475f800712e6833e14bf9ce2832f"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2591faa0c031cf3f57e5bce1461cfbd6160f3f66b5a72609a130924917cb07d"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:446d9ad04204e79229ae19502daeea56479e55cbc32634655d886f5a39e91b44"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2450d87dd7b4f277f4c5598faa8b49a0c197b91186c47a2c0b88e15531e4e3e"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b57c0954a9fdd2b05b9cec0f5a12a0bdce5bf021a5b3b09323041613972481ab"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90903d2908158a2c9077a06f11e27545de610af690fb178fd3ba6b32492d4d1c"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:266e55c83f81248f63cc93d11c5e3a53df49a5d2598fa9e9db5f99837a802d5d"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce6f095eef0026eae76fc212f20f786011ecf482fc7df2f4c272a8ae6dd7b1ef"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31372ba3a9fe8ad118e7d22fba46bbc18e89039e3bfa89db7bc8c18ee722dca8"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:751250a31fef2bac05a2da2449aae7142075ea26139271f169af60456d8ad27a"},
{file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3b0c4da61f39899561e08e571f54472a09fa71717d9797928af558175ae5243"}, {file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a1af21160a38ee8be3f4fcf24ee4b99e6184cadc7f915d599f073f478a94d2c"},
{file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cc03a35bfc71c8ebf96ce49b82c2a7be6af4b3cd3ac34166fdb42ac510bbfff"}, {file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:449bf090b2aa4e019371d7511a6ea8a5a248139205c27d1834bb4b1e3c44d936"},
{file = "orjson-3.9.13-cp312-none-win_amd64.whl", hash = "sha256:49b7e3fe861cb246361825d1a238f2584ed8ea21e714bf6bb17cebb86772e61c"}, {file = "orjson-3.9.14-cp312-none-win_amd64.whl", hash = "sha256:a603161318ff699784943e71f53899983b7dee571b4dd07c336437c9c5a272b0"},
{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.14-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:814f288c011efdf8f115c5ebcc1ab94b11da64b207722917e0ceb42f52ef30a3"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d92a3e835a5100f1d5b566fff79217eab92223ca31900dba733902a182a35ab0"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88cafb100af68af3b9b29b5ccd09fdf7a48c63327916c8c923a94c336d38dd3"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23f21faf072ed3b60b5954686f98157e073f6a8068eaa58dbde83e87212eda84"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba3518b999f88882ade6686f1b71e207b52e23546e180499be5bbb63a2f9c6e6"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:828c502bb261588f7de897e06cb23c4b122997cb039d2014cb78e7dabe92ef0c"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978f416bbff9da8d2091e3cf011c92da68b13f2c453dcc2e8109099b2a19d234"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16946d095212a3dec552572c5d9bca7afa40f3116ad49695a397be07d529f1fa"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75fc593cf836f631153d0e21beaeb8d26e144445c73645889335c2247fcd71a0"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3deadd8dc0e9ff844b5b656fa30a48dbee1c3b332d8278302dd9637f6b09f627"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d1528db3c7554f9d6eeb09df23cb80dd5177ec56eeb55cc5318826928de506"},
{file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9b1b5adc5adf596c59dca57156b71ad301d73956f5bab4039b0e34dbf50b9fa0"}, {file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7183cc68ee2113b19b0b8714221e5e3b07b3ba10ca2bb108d78fd49cefaae101"},
{file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ddc089315d030c54f0f03fb38286e2667c05009a78d659f108a8efcfbdf2e585"}, {file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df3266d54246cb56b8bb17fa908660d2a0f2e3f63fbc32451ffc1b1505051d07"},
{file = "orjson-3.9.13-cp38-none-win32.whl", hash = "sha256:ae77275a28667d9c82d4522b681504642055efa0368d73108511647c6499b31c"}, {file = "orjson-3.9.14-cp38-none-win32.whl", hash = "sha256:7913079b029e1b3501854c9a78ad938ed40d61fe09bebab3c93e60ff1301b189"},
{file = "orjson-3.9.13-cp38-none-win_amd64.whl", hash = "sha256:730385fdb99a21fce9bb84bb7fcbda72c88626facd74956bda712834b480729d"}, {file = "orjson-3.9.14-cp38-none-win_amd64.whl", hash = "sha256:29512eb925b620e5da2fd7585814485c67cc6ba4fe739a0a700c50467a8a8065"},
{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.14-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bf597530544db27a8d76aced49cfc817ee9503e0a4ebf0109cd70331e7bbe0c"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfad553a36548262e7da0f3a7464270e13900b898800fb571a5d4b298c3f8356"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac650d49366fa41fe702e054cb560171a8634e2865537e91f09a8d05ea5b1d37"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d691c44604941945b00e0a13b19a7d9c1a19511abadf0080f373e98fdeb6b31"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:236230433a9a4968ab895140514c308fdf9f607cb8bee178a04372b771123860"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8c83718346de08d68b3cb1105c5d91e5fc39885d8610fdda16613d4e3941459"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3014ccbda9be0b1b5f8ea895121df7e6524496b3908f4397ff02e923bcd8f6dd"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ef57a53bfc2091a7cd50a640d9ae866bd7d92a5225a1bab6baa60ef62583f2"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac0c7eae7ad3a223bde690565442f8a3d620056bd01196f191af8be58a5248e1"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9156b96afa38db71344522f5517077eaedf62fcd2c9148392ff93d801128809c"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca33fdd0b38839b01912c57546d4f412ba7bfa0faf9bf7453432219aec2df07"},
{file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31fb66b41fb2c4c817d9610f0bc7d31345728d7b5295ac78b63603407432a2b2"}, {file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f75823cc1674a840a151e999a7dfa0d86c911150dd6f951d0736ee9d383bf415"},
{file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8a730bf07feacb0863974e67b206b7c503a62199de1cece2eb0d4c233ec29c11"}, {file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f52ac2eb49e99e7373f62e2a68428c6946cda52ce89aa8fe9f890c7278e2d3a"},
{file = "orjson-3.9.13-cp39-none-win32.whl", hash = "sha256:5ef58869f3399acbbe013518d8b374ee9558659eef14bca0984f67cb1fbd3c37"}, {file = "orjson-3.9.14-cp39-none-win32.whl", hash = "sha256:0572f174f50b673b7df78680fb52cd0087a8585a6d06d295a5f790568e1064c6"},
{file = "orjson-3.9.13-cp39-none-win_amd64.whl", hash = "sha256:9bcf56efdb83244cde070e82a69c0f03c47c235f0a5cb6c81d9da23af7fbaae4"}, {file = "orjson-3.9.14-cp39-none-win_amd64.whl", hash = "sha256:ab90c02cb264250b8a58cedcc72ed78a4a257d956c8d3c8bebe9751b818dfad8"},
{file = "orjson-3.9.13.tar.gz", hash = "sha256:fc6bc65b0cf524ee042e0bc2912b9206ef242edfba7426cf95763e4af01f527a"}, {file = "orjson-3.9.14.tar.gz", hash = "sha256:06fb40f8e49088ecaa02f1162581d39e2cf3fd9dbbfe411eb2284147c99bad79"},
] ]
[[package]] [[package]]
@ -3096,121 +3096,121 @@ all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib"
[[package]] [[package]]
name = "tokenizers" name = "tokenizers"
version = "0.15.1" version = "0.15.2"
description = "" description = ""
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "tokenizers-0.15.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:32c9491dd1bcb33172c26b454dbd607276af959b9e78fa766e2694cafab3103c"}, {file = "tokenizers-0.15.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:52f6130c9cbf70544287575a985bf44ae1bda2da7e8c24e97716080593638012"},
{file = "tokenizers-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29a1b784b870a097e7768f8c20c2dd851e2c75dad3efdae69a79d3e7f1d614d5"}, {file = "tokenizers-0.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:054c1cc9c6d68f7ffa4e810b3d5131e0ba511b6e4be34157aa08ee54c2f8d9ee"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0049fbe648af04148b08cb211994ce8365ee628ce49724b56aaefd09a3007a78"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9b9b070fdad06e347563b88c278995735292ded1132f8657084989a4c84a6d5"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e84b3c235219e75e24de6b71e6073cd2c8d740b14d88e4c6d131b90134e3a338"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea621a7eef4b70e1f7a4e84dd989ae3f0eeb50fc8690254eacc08acb623e82f1"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8cc575769ea11d074308c6d71cb10b036cdaec941562c07fc7431d956c502f0e"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7fd9a5141634fa3aa8d6b7be362e6ae1b4cda60da81388fa533e0b552c98fd"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bf28f299c4158e6d0b5eaebddfd500c4973d947ffeaca8bcbe2e8c137dff0b"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44f2a832cd0825295f7179eaf173381dc45230f9227ec4b44378322d900447c9"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:506555f98361db9c74e1323a862d77dcd7d64c2058829a368bf4159d986e339f"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b9ec69247a23747669ec4b0ca10f8e3dfb3545d550258129bd62291aabe8605"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7061b0a28ade15906f5b2ec8c48d3bdd6e24eca6b427979af34954fbe31d5cef"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b6a4c78da863ff26dbd5ad9a8ecc33d8a8d97b535172601cf00aee9d7ce9ce"},
{file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ed5e35507b7a0e2aac3285c4f5e37d4ec5cfc0e5825b862b68a0aaf2757af52"}, {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5ab2a4d21dcf76af60e05af8063138849eb1d6553a0d059f6534357bce8ba364"},
{file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c9df9247df0de6509dd751b1c086e5f124b220133b5c883bb691cb6fb3d786f"}, {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a47acfac7e511f6bbfcf2d3fb8c26979c780a91e06fb5b9a43831b2c0153d024"},
{file = "tokenizers-0.15.1-cp310-none-win32.whl", hash = "sha256:dd999af1b4848bef1b11d289f04edaf189c269d5e6afa7a95fa1058644c3f021"}, {file = "tokenizers-0.15.2-cp310-none-win32.whl", hash = "sha256:064ff87bb6acdbd693666de9a4b692add41308a2c0ec0770d6385737117215f2"},
{file = "tokenizers-0.15.1-cp310-none-win_amd64.whl", hash = "sha256:39d06a57f7c06940d602fad98702cf7024c4eee7f6b9fe76b9f2197d5a4cc7e2"}, {file = "tokenizers-0.15.2-cp310-none-win_amd64.whl", hash = "sha256:3b919afe4df7eb6ac7cafd2bd14fb507d3f408db7a68c43117f579c984a73843"},
{file = "tokenizers-0.15.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8ad034eb48bf728af06915e9294871f72fcc5254911eddec81d6df8dba1ce055"}, {file = "tokenizers-0.15.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:89cd1cb93e4b12ff39bb2d626ad77e35209de9309a71e4d3d4672667b4b256e7"},
{file = "tokenizers-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea9ede7c42f8fa90f31bfc40376fd91a7d83a4aa6ad38e6076de961d48585b26"}, {file = "tokenizers-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfed5c64e5be23d7ee0f0e98081a25c2a46b0b77ce99a4f0605b1ec43dd481fa"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b85d6fe1a20d903877aa0ef32ef6b96e81e0e48b71c206d6046ce16094de6970"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a907d76dcfda37023ba203ab4ceeb21bc5683436ebefbd895a0841fd52f6f6f2"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a7d44f656320137c7d643b9c7dcc1814763385de737fb98fd2643880910f597"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ea60479de6fc7b8ae756b4b097572372d7e4032e2521c1bbf3d90c90a99ff0"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd244bd0793cdacf27ee65ec3db88c21f5815460e8872bbeb32b040469d6774e"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48e2b9335be2bc0171df9281385c2ed06a15f5cf121c44094338306ab7b33f2c"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3f4a36e371b3cb1123adac8aeeeeab207ad32f15ed686d9d71686a093bb140"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:112a1dd436d2cc06e6ffdc0b06d55ac019a35a63afd26475205cb4b1bf0bfbff"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2921a53966afb29444da98d56a6ccbef23feb3b0c0f294b4e502370a0a64f25"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4620cca5c2817177ee8706f860364cc3a8845bc1e291aaf661fb899e5d1c45b0"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f49068cf51f49c231067f1a8c9fc075ff960573f6b2a956e8e1b0154fb638ea5"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccd73a82751c523b3fc31ff8194702e4af4db21dc20e55b30ecc2079c5d43cb7"},
{file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0ab1a22f20eaaab832ab3b00a0709ca44a0eb04721e580277579411b622c741c"}, {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:107089f135b4ae7817affe6264f8c7a5c5b4fd9a90f9439ed495f54fcea56fb4"},
{file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:671268f24b607c4adc6fa2b5b580fd4211b9f84b16bd7f46d62f8e5be0aa7ba4"}, {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0ff110ecc57b7aa4a594396525a3451ad70988e517237fe91c540997c4e50e29"},
{file = "tokenizers-0.15.1-cp311-none-win32.whl", hash = "sha256:a4f03e33d2bf7df39c8894032aba599bf90f6f6378e683a19d28871f09bb07fc"}, {file = "tokenizers-0.15.2-cp311-none-win32.whl", hash = "sha256:6d76f00f5c32da36c61f41c58346a4fa7f0a61be02f4301fd30ad59834977cc3"},
{file = "tokenizers-0.15.1-cp311-none-win_amd64.whl", hash = "sha256:30f689537bcc7576d8bd4daeeaa2cb8f36446ba2f13f421b173e88f2d8289c4e"}, {file = "tokenizers-0.15.2-cp311-none-win_amd64.whl", hash = "sha256:cc90102ed17271cf0a1262babe5939e0134b3890345d11a19c3145184b706055"},
{file = "tokenizers-0.15.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f3a379dd0898a82ea3125e8f9c481373f73bffce6430d4315f0b6cd5547e409"}, {file = "tokenizers-0.15.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f86593c18d2e6248e72fb91c77d413a815153b8ea4e31f7cd443bdf28e467670"},
{file = "tokenizers-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d870ae58bba347d38ac3fc8b1f662f51e9c95272d776dd89f30035c83ee0a4f"}, {file = "tokenizers-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0774bccc6608eca23eb9d620196687c8b2360624619623cf4ba9dc9bd53e8b51"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d6d28e0143ec2e253a8a39e94bf1d24776dbe73804fa748675dbffff4a5cd6d8"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0222c5b7c9b26c0b4822a82f6a7011de0a9d3060e1da176f66274b70f846b98"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61ae9ac9f44e2da128ee35db69489883b522f7abe033733fa54eb2de30dac23d"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3835738be1de66624fff2f4f6f6684775da4e9c00bde053be7564cbf3545cc66"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8e322a47e29128300b3f7749a03c0ec2bce0a3dc8539ebff738d3f59e233542"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0143e7d9dcd811855c1ce1ab9bf5d96d29bf5e528fd6c7824d0465741e8c10fd"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:760334f475443bc13907b1a8e1cb0aeaf88aae489062546f9704dce6c498bfe2"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db35825f6d54215f6b6009a7ff3eedee0848c99a6271c870d2826fbbedf31a38"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b173753d4aca1e7d0d4cb52b5e3ffecfb0ca014e070e40391b6bb4c1d6af3f2"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f5e64b0389a2be47091d8cc53c87859783b837ea1a06edd9d8e04004df55a5c"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1f13d457c8f0ab17e32e787d03470067fe8a3b4d012e7cc57cb3264529f4a"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e0480c452217edd35eca56fafe2029fb4d368b7c0475f8dfa3c5c9c400a7456"},
{file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:425b46ceff4505f20191df54b50ac818055d9d55023d58ae32a5d895b6f15bb0"}, {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a33ab881c8fe70474980577e033d0bc9a27b7ab8272896e500708b212995d834"},
{file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:681ac6ba3b4fdaf868ead8971221a061f580961c386e9732ea54d46c7b72f286"}, {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a308a607ca9de2c64c1b9ba79ec9a403969715a1b8ba5f998a676826f1a7039d"},
{file = "tokenizers-0.15.1-cp312-none-win32.whl", hash = "sha256:f2272656063ccfba2044df2115095223960d80525d208e7a32f6c01c351a6f4a"}, {file = "tokenizers-0.15.2-cp312-none-win32.whl", hash = "sha256:b8fcfa81bcb9447df582c5bc96a031e6df4da2a774b8080d4f02c0c16b42be0b"},
{file = "tokenizers-0.15.1-cp312-none-win_amd64.whl", hash = "sha256:9abe103203b1c6a2435d248d5ff4cceebcf46771bfbc4957a98a74da6ed37674"}, {file = "tokenizers-0.15.2-cp312-none-win_amd64.whl", hash = "sha256:38d7ab43c6825abfc0b661d95f39c7f8af2449364f01d331f3b51c94dcff7221"},
{file = "tokenizers-0.15.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2ce9ed5c8ef26b026a66110e3c7b73d93ec2d26a0b1d0ea55ddce61c0e5f446f"}, {file = "tokenizers-0.15.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:38bfb0204ff3246ca4d5e726e8cc8403bfc931090151e6eede54d0e0cf162ef0"},
{file = "tokenizers-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89b24d366137986c3647baac29ef902d2d5445003d11c30df52f1bd304689aeb"}, {file = "tokenizers-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c861d35e8286a53e06e9e28d030b5a05bcbf5ac9d7229e561e53c352a85b1fc"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0faebedd01b413ab777ca0ee85914ed8b031ea5762ab0ea60b707ce8b9be6842"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:936bf3842db5b2048eaa53dade907b1160f318e7c90c74bfab86f1e47720bdd6"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdbd9dfcdad4f3b95d801f768e143165165055c18e44ca79a8a26de889cd8e85"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:620beacc3373277700d0e27718aa8b25f7b383eb8001fba94ee00aeea1459d89"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97194324c12565b07e9993ca9aa813b939541185682e859fb45bb8d7d99b3193"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2735ecbbf37e52db4ea970e539fd2d450d213517b77745114f92867f3fc246eb"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485e43e2cc159580e0d83fc919ec3a45ae279097f634b1ffe371869ffda5802c"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:473c83c5e2359bb81b0b6fde870b41b2764fcdd36d997485e07e72cc3a62264a"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191d084d60e3589d6420caeb3f9966168269315f8ec7fbc3883122dc9d99759d"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968fa1fb3c27398b28a4eca1cbd1e19355c4d3a6007f7398d48826bbe3a0f728"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01c28cc8d7220634a75b14c53f4fc9d1b485f99a5a29306a999c115921de2897"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:865c60ae6eaebdde7da66191ee9b7db52e542ed8ee9d2c653b6d190a9351b980"},
{file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:325212027745d3f8d5d5006bb9e5409d674eb80a184f19873f4f83494e1fdd26"}, {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7c0d8b52664ab2d4a8d6686eb5effc68b78608a9008f086a122a7b2996befbab"},
{file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3c5573603c36ce12dbe318bcfb490a94cad2d250f34deb2f06cb6937957bbb71"}, {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f33dfbdec3784093a9aebb3680d1f91336c56d86cc70ddf88708251da1fe9064"},
{file = "tokenizers-0.15.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:1441161adb6d71a15a630d5c1d8659d5ebe41b6b209586fbeea64738e58fcbb2"}, {file = "tokenizers-0.15.2-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:d44ba80988ff9424e33e0a49445072ac7029d8c0e1601ad25a0ca5f41ed0c1d6"},
{file = "tokenizers-0.15.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:382a8d0c31afcfb86571afbfefa37186df90865ce3f5b731842dab4460e53a38"}, {file = "tokenizers-0.15.2-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:dce74266919b892f82b1b86025a613956ea0ea62a4843d4c4237be2c5498ed3a"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e76959783e3f4ec73b3f3d24d4eec5aa9225f0bee565c48e77f806ed1e048f12"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0ef06b9707baeb98b316577acb04f4852239d856b93e9ec3a299622f6084e4be"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:401df223e5eb927c5961a0fc6b171818a2bba01fb36ef18c3e1b69b8cd80e591"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73e2e74bbb07910da0d37c326869f34113137b23eadad3fc00856e6b3d9930c"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52606c233c759561a16e81b2290a7738c3affac7a0b1f0a16fe58dc22e04c7d"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eeb12daf02a59e29f578a865f55d87cd103ce62bd8a3a5874f8fdeaa82e336b"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72c658bbe5a05ed8bc2ac5ad782385bfd743ffa4bc87d9b5026341e709c6f44"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ba9f6895af58487ca4f54e8a664a322f16c26bbb442effd01087eba391a719e"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25f5643a2f005c42f0737a326c6c6bdfedfdc9a994b10a1923d9c3e792e4d6a6"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccec77aa7150e38eec6878a493bf8c263ff1fa8a62404e16c6203c64c1f16a26"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5b6f633999d6b42466bbfe21be2e26ad1760b6f106967a591a41d8cbca980e"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f40604f5042ff210ba82743dda2b6aa3e55aa12df4e9f2378ee01a17e2855e"},
{file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ceb5c9ad11a015150b545c1a11210966a45b8c3d68a942e57cf8938c578a77ca"}, {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5645938a42d78c4885086767c70923abad047163d809c16da75d6b290cb30bbe"},
{file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bedd4ce0c4872db193444c395b11c7697260ce86a635ab6d48102d76be07d324"}, {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:05a77cbfebe28a61ab5c3891f9939cc24798b63fa236d84e5f29f3a85a200c00"},
{file = "tokenizers-0.15.1-cp37-none-win32.whl", hash = "sha256:cd6caef6c14f5ed6d35f0ddb78eab8ca6306d0cd9870330bccff72ad014a6f42"}, {file = "tokenizers-0.15.2-cp37-none-win32.whl", hash = "sha256:361abdc068e8afe9c5b818769a48624687fb6aaed49636ee39bec4e95e1a215b"},
{file = "tokenizers-0.15.1-cp37-none-win_amd64.whl", hash = "sha256:d2bd7af78f58d75a55e5df61efae164ab9200c04b76025f9cc6eeb7aff3219c2"}, {file = "tokenizers-0.15.2-cp37-none-win_amd64.whl", hash = "sha256:7ef789f83eb0f9baeb4d09a86cd639c0a5518528f9992f38b28e819df397eb06"},
{file = "tokenizers-0.15.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:59b3ca6c02e0bd5704caee274978bd055de2dff2e2f39dadf536c21032dfd432"}, {file = "tokenizers-0.15.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4fe1f74a902bee74a3b25aff180fbfbf4f8b444ab37c4d496af7afd13a784ed2"},
{file = "tokenizers-0.15.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:48fe21b67c22583bed71933a025fd66b1f5cfae1baefa423c3d40379b5a6e74e"}, {file = "tokenizers-0.15.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c4b89038a684f40a6b15d6b09f49650ac64d951ad0f2a3ea9169687bbf2a8ba"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3d190254c66a20fb1efbdf035e6333c5e1f1c73b1f7bfad88f9c31908ac2c2c4"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d05a1b06f986d41aed5f2de464c003004b2df8aaf66f2b7628254bcbfb72a438"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef90c8f5abf17d48d6635f5fd92ad258acd1d0c2d920935c8bf261782cfe7c8"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:508711a108684111ec8af89d3a9e9e08755247eda27d0ba5e3c50e9da1600f6d"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fac011ef7da3357aa7eb19efeecf3d201ede9618f37ddedddc5eb809ea0963ca"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daa348f02d15160cb35439098ac96e3a53bacf35885072611cd9e5be7d333daa"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:574ec5b3e71d1feda6b0ecac0e0445875729b4899806efbe2b329909ec75cb50"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:494fdbe5932d3416de2a85fc2470b797e6f3226c12845cadf054dd906afd0442"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aca16c3c0637c051a59ea99c4253f16fbb43034fac849076a7e7913b2b9afd2d"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2d60f5246f4da9373f75ff18d64c69cbf60c3bca597290cea01059c336d2470"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6f238fc2bbfd3e12e8529980ec1624c7e5b69d4e959edb3d902f36974f725a"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93268e788825f52de4c7bdcb6ebc1fcd4a5442c02e730faa9b6b08f23ead0e24"},
{file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:587e11a26835b73c31867a728f32ca8a93c9ded4a6cd746516e68b9d51418431"}, {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6fc7083ab404019fc9acafe78662c192673c1e696bd598d16dc005bd663a5cf9"},
{file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6456e7ad397352775e2efdf68a9ec5d6524bbc4543e926eef428d36de627aed4"}, {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e39b41e5531d6b2122a77532dbea60e171ef87a3820b5a3888daa847df4153"},
{file = "tokenizers-0.15.1-cp38-none-win32.whl", hash = "sha256:614f0da7dd73293214bd143e6221cafd3f7790d06b799f33a987e29d057ca658"}, {file = "tokenizers-0.15.2-cp38-none-win32.whl", hash = "sha256:06cd0487b1cbfabefb2cc52fbd6b1f8d4c37799bd6c6e1641281adaa6b2504a7"},
{file = "tokenizers-0.15.1-cp38-none-win_amd64.whl", hash = "sha256:a4fa0a20d9f69cc2bf1cfce41aa40588598e77ec1d6f56bf0eb99769969d1ede"}, {file = "tokenizers-0.15.2-cp38-none-win_amd64.whl", hash = "sha256:5179c271aa5de9c71712e31cb5a79e436ecd0d7532a408fa42a8dbfa4bc23fd9"},
{file = "tokenizers-0.15.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8d3f18a45e0cf03ce193d5900460dc2430eec4e14c786e5d79bddba7ea19034f"}, {file = "tokenizers-0.15.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82f8652a74cc107052328b87ea8b34291c0f55b96d8fb261b3880216a9f9e48e"},
{file = "tokenizers-0.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:38dbd6c38f88ad7d5dc5d70c764415d38fe3bcd99dc81638b572d093abc54170"}, {file = "tokenizers-0.15.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02458bee6f5f3139f1ebbb6d042b283af712c0981f5bc50edf771d6b762d5e4f"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:777286b1f7e52de92aa4af49fe31046cfd32885d1bbaae918fab3bba52794c33"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9a09cd26cca2e1c349f91aa665309ddb48d71636370749414fbf67bc83c5343"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d4d550a3862a47dd249892d03a025e32286eb73cbd6bc887fb8fb64bc97165"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158be8ea8554e5ed69acc1ce3fbb23a06060bd4bbb09029431ad6b9a466a7121"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eda68ce0344f35042ae89220b40a0007f721776b727806b5c95497b35714bb7"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ddba9a2b0c8c81633eca0bb2e1aa5b3a15362b1277f1ae64176d0f6eba78ab1"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cd33d15f7a3a784c3b665cfe807b8de3c6779e060349bd5005bb4ae5bdcb437"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ef5dd1d39797044642dbe53eb2bc56435308432e9c7907728da74c69ee2adca"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a1aa370f978ac0bfb50374c3a40daa93fd56d47c0c70f0c79607fdac2ccbb42"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:454c203164e07a860dbeb3b1f4a733be52b0edbb4dd2e5bd75023ffa8b49403a"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:241482b940340fff26a2708cb9ba383a5bb8a2996d67a0ff2c4367bf4b86cc3a"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cf6b7f1d4dc59af960e6ffdc4faffe6460bbfa8dce27a58bf75755ffdb2526d"},
{file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:68f30b05f46a4d9aba88489eadd021904afe90e10a7950e28370d6e71b9db021"}, {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2ef09bbc16519f6c25d0c7fc0c6a33a6f62923e263c9d7cca4e58b8c61572afb"},
{file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5a3c5d8025529670462b881b7b2527aacb6257398c9ec8e170070432c3ae3a82"}, {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c9a2ebdd2ad4ec7a68e7615086e633857c85e2f18025bd05d2a4399e6c5f7169"},
{file = "tokenizers-0.15.1-cp39-none-win32.whl", hash = "sha256:74d1827830f60a9d78da8f6d49a1fbea5422ce0eea42e2617877d23380a7efbc"}, {file = "tokenizers-0.15.2-cp39-none-win32.whl", hash = "sha256:918fbb0eab96fe08e72a8c2b5461e9cce95585d82a58688e7f01c2bd546c79d0"},
{file = "tokenizers-0.15.1-cp39-none-win_amd64.whl", hash = "sha256:9ff499923e4d6876d6b6a63ea84a56805eb35e91dd89b933a7aee0c56a3838c6"}, {file = "tokenizers-0.15.2-cp39-none-win_amd64.whl", hash = "sha256:524e60da0135e106b254bd71f0659be9f89d83f006ea9093ce4d1fab498c6d0d"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b3aa007a0f4408f62a8471bdaa3faccad644cbf2622639f2906b4f9b5339e8b8"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6a9b648a58281c4672212fab04e60648fde574877d0139cd4b4f93fe28ca8944"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f3d4176fa93d8b2070db8f3c70dc21106ae6624fcaaa334be6bdd3a0251e729e"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7c7d18b733be6bbca8a55084027f7be428c947ddf871c500ee603e375013ffba"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d0e463655ef8b2064df07bd4a445ed7f76f6da3b286b4590812587d42f80e89"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:13ca3611de8d9ddfbc4dc39ef54ab1d2d4aaa114ac8727dfdc6a6ec4be017378"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:089138fd0351b62215c462a501bd68b8df0e213edcf99ab9efd5dba7b4cb733e"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:237d1bf3361cf2e6463e6c140628e6406766e8b27274f5fcc62c747ae3c6f094"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e563ac628f5175ed08e950430e2580e544b3e4b606a0995bb6b52b3a3165728"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67a0fe1e49e60c664915e9fb6b0cb19bac082ab1f309188230e4b2920230edb3"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:244dcc28c5fde221cb4373961b20da30097669005b122384d7f9f22752487a46"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4e022fe65e99230b8fd89ebdfea138c24421f91c1a4f4781a8f5016fd5cdfb4d"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d82951d46052dddae1369e68ff799a0e6e29befa9a0b46e387ae710fd4daefb0"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d857be2df69763362ac699f8b251a8cd3fac9d21893de129bc788f8baaef2693"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b14296bc9059849246ceb256ffbe97f8806a9b5d707e0095c22db312f4fc014"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:708bb3e4283177236309e698da5fcd0879ce8fd37457d7c266d16b550bcbbd18"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0309357bb9b6c8d86cdf456053479d7112074b470651a997a058cd7ad1c4ea57"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c35e09e9899b72a76e762f9854e8750213f67567787d45f37ce06daf57ca78"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083f06e9d8d01b70b67bcbcb7751b38b6005512cce95808be6bf34803534a7e7"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1257f4394be0d3b00de8c9e840ca5601d0a4a8438361ce9c2b05c7d25f6057b"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85288aea86ada579789447f0dcec108ebef8da4b450037eb4813d83e4da9371e"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02272fe48280e0293a04245ca5d919b2c94a48b408b55e858feae9618138aeda"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:385e6fcb01e8de90c1d157ae2a5338b23368d0b1c4cc25088cdca90147e35d17"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dc3ad9ebc76eabe8b1d7c04d38be884b8f9d60c0cdc09b0aa4e3bcf746de0388"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:60067edfcbf7d6cd448ac47af41ec6e84377efbef7be0c06f15a7c1dd069e044"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:32e16bdeffa7c4f46bf2152172ca511808b952701d13e7c18833c0b73cb5c23f"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f7e37f89acfe237d4eaf93c3b69b0f01f407a7a5d0b5a8f06ba91943ea3cf10"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fb16ba563d59003028b678d2361a27f7e4ae0ab29c7a80690efa20d829c81fdb"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:6a63a15b523d42ebc1f4028e5a568013388c2aefa4053a263e511cb10aaa02f1"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:2277c36d2d6cdb7876c274547921a42425b6810d38354327dd65a8009acf870c"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2417d9e4958a6c2fbecc34c27269e74561c55d8823bf914b422e261a11fdd5fd"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1cf75d32e8d250781940d07f7eece253f2fe9ecdb1dc7ba6e3833fa17b82fcbc"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8550974bace6210e41ab04231e06408cf99ea4279e0862c02b8d47e7c2b2828"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b3b31884dc8e9b21508bb76da80ebf7308fdb947a17affce815665d5c4d028"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:194ba82129b171bcd29235a969e5859a93e491e9b0f8b2581f500f200c85cfdd"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10122d8d8e30afb43bb1fe21a3619f62c3e2574bff2699cf8af8b0b6c5dc4a3"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1bfd95eef8b01e6c0805dbccc8eaf41d8c5a84f0cce72c0ab149fe76aae0bce6"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d88b96ff0fe8e91f6ef01ba50b0d71db5017fa4e3b1d99681cec89a85faf7bf7"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b87a15dd72f8216b03c151e3dace00c75c3fe7b0ee9643c25943f31e582f1a34"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:37aaec5a52e959892870a7c47cef80c53797c0db9149d458460f4f31e2fb250e"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6ac22f358a0c2a6c685be49136ce7ea7054108986ad444f567712cf274b34cd8"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2ea752f2b0fe96eb6e2f3adbbf4d72aaa1272079b0dfa1145507bd6a5d537e6"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e9d1f046a9b9d9a95faa103f07db5921d2c1c50f0329ebba4359350ee02b18b"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b19a808d8799fda23504a5cd31d2f58e6f52f140380082b352f877017d6342b"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a0fd30a4b74485f6a7af89fffb5fb84d6d5f649b3e74f8d37f624cc9e9e97cf"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c86e5e068ac8b19204419ed8ca90f9d25db20578f5881e337d203b314f4104"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e45dc206b9447fa48795a1247c69a1732d890b53e2cc51ba42bc2fefa22407"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de19c4dc503c612847edf833c82e9f73cd79926a384af9d801dcf93f110cea4e"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eaff56ef3e218017fa1d72007184401f04cb3a289990d2b6a0a76ce71c95f96"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea09acd2fe3324174063d61ad620dec3bcf042b495515f27f638270a7d466e8b"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b41dc107e4a4e9c95934e79b025228bbdda37d9b153d8b084160e88d5e48ad6f"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cf27fd43472e07b57cf420eee1e814549203d56de00b5af8659cb99885472f1f"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1922b8582d0c33488764bcf32e80ef6054f515369e70092729c928aae2284bc2"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7ca22bd897537a0080521445d91a58886c8c04084a6a19e6c78c586e0cfa92a5"},
{file = "tokenizers-0.15.1.tar.gz", hash = "sha256:c0a331d6d5a3d6e97b7f99f562cee8d56797180797bc55f12070e495e717c980"}, {file = "tokenizers-0.15.2.tar.gz", hash = "sha256:e6e9c6e019dd5484be5beafc775ae6c925f4c69a3487040ed09b45e13df2cb91"},
] ]
[package.dependencies] [package.dependencies]
@ -3281,13 +3281,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.27.0.post1" version = "0.27.1"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f"}, {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"},
{file = "uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907"}, {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"},
] ]
[package.dependencies] [package.dependencies]

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.95.0" version = "1.95.1"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
@ -82,10 +82,10 @@ warn_untyped_fields = true
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120
target-version = "py311" target-version = "py311"
select = ["E", "F", "I"]
[tool.ruff.per-file-ignores] [tool.ruff.lint]
"test_main.py" = ["F403"] select = ["E", "F", "I"]
per-file-ignores = { "test_main.py" = ["F403"] }
[tool.black] [tool.black]
line-length = 120 line-length = 120

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 122, "android.injected.version.code" => 123,
"android.injected.version.name" => "1.95.0", "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') 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')

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.95.0" version_number: "1.95.1"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View file

@ -30,7 +30,7 @@ extension LogOnError<T> on AsyncValue<T> {
} }
if (hasError && !hasValue) { if (hasError && !hasValue) {
_asyncErrorLogger.severe("$error", error, stackTrace); _asyncErrorLogger.severe('Could not load value', error, stackTrace);
return onError?.call(error, stackTrace) ?? return onError?.call(error, stackTrace) ??
ScaffoldErrorBody(errorMsg: error?.toString()); ScaffoldErrorBody(errorMsg: error?.toString());
} }

View file

@ -0,0 +1,5 @@
import 'package:http/http.dart';
extension LoggerExtension on Response {
String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body";
}

View file

@ -73,15 +73,14 @@ Future<void> initApp() async {
FlutterError.onError = (details) { FlutterError.onError = (details) {
FlutterError.presentError(details); FlutterError.presentError(details);
log.severe( log.severe(
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}', 'FlutterError - Catch all',
details, "${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
details.stack, details.stack,
); );
}; };
PlatformDispatcher.instance.onError = (error, stack) { PlatformDispatcher.instance.onError = (error, stack) {
log.severe('PlatformDispatcher - Catch all error: $error', error, stack); log.severe('PlatformDispatcher - Catch all', error, stack);
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
return true; return true;
}; };

View file

@ -10,13 +10,14 @@ mixin ErrorLoggerMixin {
/// Else, logs the error to the overrided logger and returns an AsyncError<> /// Else, logs the error to the overrided logger and returns an AsyncError<>
AsyncFuture<T> guardError<T>( AsyncFuture<T> guardError<T>(
Future<T> Function() fn, { Future<T> Function() fn, {
required String errorMessage,
Level logLevel = Level.SEVERE, Level logLevel = Level.SEVERE,
}) async { }) async {
try { try {
final result = await fn(); final result = await fn();
return AsyncData(result); return AsyncData(result);
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace); logger.log(logLevel, errorMessage, error, stackTrace);
return AsyncError(error, stackTrace); return AsyncError(error, stackTrace);
} }
} }
@ -26,12 +27,13 @@ mixin ErrorLoggerMixin {
Future<T> logError<T>( Future<T> logError<T>(
Future<T> Function() fn, { Future<T> Function() fn, {
required T defaultValue, required T defaultValue,
required String errorMessage,
Level logLevel = Level.SEVERE, Level logLevel = Level.SEVERE,
}) async { }) async {
try { try {
return await fn(); return await fn();
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace); logger.log(logLevel, errorMessage, error, stackTrace);
} }
return defaultValue; return defaultValue;
} }

View file

@ -24,6 +24,7 @@ class ActivityService with ErrorLoggerMixin {
return list != null ? list.map(Activity.fromDto).toList() : []; return list != null ? list.map(Activity.fromDto).toList() : [];
}, },
defaultValue: [], defaultValue: [],
errorMessage: "Failed to get all activities for album $albumId",
); );
} }
@ -35,6 +36,7 @@ class ActivityService with ErrorLoggerMixin {
return dto?.comments ?? 0; return dto?.comments ?? 0;
}, },
defaultValue: 0, defaultValue: 0,
errorMessage: "Failed to statistics for album $albumId",
); );
} }
@ -45,6 +47,7 @@ class ActivityService with ErrorLoggerMixin {
return true; return true;
}, },
defaultValue: false, defaultValue: false,
errorMessage: "Failed to delete activity",
); );
} }
@ -54,21 +57,24 @@ class ActivityService with ErrorLoggerMixin {
String? assetId, String? assetId,
String? comment, String? comment,
}) async { }) async {
return guardError(() async { return guardError(
final dto = await _apiService.activityApi.createActivity( () async {
ActivityCreateDto( final dto = await _apiService.activityApi.createActivity(
albumId: albumId, ActivityCreateDto(
type: type == ActivityType.comment albumId: albumId,
? ReactionType.comment type: type == ActivityType.comment
: ReactionType.like, ? ReactionType.comment
assetId: assetId, : ReactionType.like,
comment: comment, assetId: assetId,
), comment: comment,
); ),
if (dto != null) { );
return Activity.fromDto(dto); if (dto != null) {
} return Activity.fromDto(dto);
throw NoResponseDtoError(); }
}); throw NoResponseDtoError();
},
errorMessage: "Failed to create $type for album $albumId",
);
} }
} }

View file

@ -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<ChewieController?> {
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?, _ChewieControllerHook> {
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<void> _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,
);
});
}
}

View file

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.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/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
@ -39,7 +40,8 @@ class ImageViewerService {
final failedResponse = final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionReponse; imageResponse.statusCode != 200 ? imageResponse : motionReponse;
_log.severe( _log.severe(
"Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}", "Motion asset download failed",
failedResponse.toLoggerString(),
); );
return false; return false;
} }
@ -75,9 +77,7 @@ class ImageViewerService {
.downloadFileWithHttpInfo(asset.remoteId!); .downloadFileWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe( _log.severe("Asset download failed", res.toLoggerString());
"Asset download failed with status - ${res.statusCode} and response - ${res.body}",
);
return false; return false;
} }
@ -98,7 +98,7 @@ class ImageViewerService {
return entity != null; return entity != null;
} }
} catch (error, stack) { } catch (error, stack) {
_log.severe("Error saving file ${error.toString()}", error, stack); _log.severe("Error saving downloaded asset", error, stack);
return false; return false;
} finally { } finally {
// Clear temp files // Clear temp files

View file

@ -48,7 +48,7 @@ class DescriptionInput extends HookConsumerWidget {
); );
} catch (error, stack) { } catch (error, stack) {
hasError.value = true; hasError.value = true;
_log.severe("Error updating description $error", error, stack); _log.severe("Error updating description", error, stack);
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "description_input_submit_error".tr(), msg: "description_input_submit_error".tr(),

View file

@ -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_controls_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_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/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'; import 'package:video_player/video_player.dart';
class VideoPlayerControls extends ConsumerStatefulWidget { class VideoPlayerControls extends ConsumerStatefulWidget {
@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
children: [ children: [
if (_displayBufferingIndicator) if (_displayBufferingIndicator)
const Center( const Center(
child: ImmichLoadingIndicator(), child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
) )
else else
_buildHitArea(), _buildHitArea(),
@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
@override @override
void dispose() { void dispose() {
_dispose(); _dispose();
super.dispose(); super.dispose();
} }
@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
final oldController = _chewieController; final oldController = _chewieController;
_chewieController = ChewieController.of(context); _chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController; controller = chewieController.videoPlayerController;
_latestValue = controller.value;
if (oldController != chewieController) { if (oldController != chewieController) {
_dispose(); _dispose();
@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (_latestValue.isPlaying) { if (!_latestValue.isPlaying) {
ref.read(showControlsProvider.notifier).show = false;
} else {
_playPause(); _playPause();
ref.read(showControlsProvider.notifier).show = false;
} }
ref.read(showControlsProvider.notifier).show = false;
}, },
child: CenterPlayButton( child: CenterPlayButton(
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
} }
Future<void> _initialize() async { Future<void> _initialize() async {
ref.read(showControlsProvider.notifier).show = false;
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
controller.addListener(_updateState);
_latestValue = controller.value; _latestValue = controller.value;
controller.addListener(_updateState);
if (controller.value.isPlaying || chewieController.autoPlay) { if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer(); _startHideTimer();
@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
} }
void _startHideTimer() { void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer.isNegative final hideControlsTimer = chewieController.hideControlsTimer;
? ChewieController.defaultHideControlsTimer _hideTimer?.cancel();
: chewieController.hideControlsTimer;
_hideTimer = Timer(hideControlsTimer, () { _hideTimer = Timer(hideControlsTimer, () {
ref.read(showControlsProvider.notifier).show = false; ref.read(showControlsProvider.notifier).show = false;
}); });

View file

@ -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) { ref.listen(showControlsProvider, (_, show) {
if (show) { if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -795,7 +807,9 @@ class GalleryViewerPage extends HookConsumerWidget {
minScale: 1.0, minScale: 1.0,
basePosition: Alignment.center, basePosition: Alignment.center,
child: VideoViewerPage( child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true, onPlaying: () {
isPlayingVideo.value = true;
},
onPaused: () => onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false, (_) => isPlayingVideo.value = false,

View file

@ -1,23 +1,15 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.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/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.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';
@RoutePage() @RoutePage()
// ignore: must_be_immutable // ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget { class VideoViewerPage extends HookWidget {
final Asset asset; final Asset asset;
final bool isMotionVideo; final bool isMotionVideo;
final Widget? placeholder; final Widget? placeholder;
@ -42,211 +34,49 @@ class VideoViewerPage extends HookConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
if (asset.isLocal && asset.livePhotoVideoId == null) { final controller = useChewieController(
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!)); asset,
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<File, AssetEntity>((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<VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
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<void> 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(
controlsSafeAreaMinimum: const EdgeInsets.only( controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100, bottom: 100,
), ),
showOptions: true, placeholder: placeholder,
showControlsOnInitialize: false, showControls: showControls && !isMotionVideo,
videoPlayerController: videoPlayerController, hideControlsTimer: hideControlsTimer,
autoPlay: true,
autoInitialize: true,
allowFullScreen: false,
allowedScreenSleep: false,
showControls: widget.showControls && !widget.isMotionVideo,
customControls: const VideoPlayerControls(), 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(),
),
],
),
),
);
}
}
} }

View file

@ -245,7 +245,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} catch (e, stack) { } catch (e, stack) {
log.severe( log.severe(
"Failed to get thumbnail for album ${album.name}", "Failed to get thumbnail for album ${album.name}",
e.toString(), e,
stack, stack,
); );
} }

View file

@ -108,7 +108,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
.then((_) => log.info("Logout was successful for $userEmail")) .then((_) => log.info("Logout was successful for $userEmail"))
.onError( .onError(
(error, stackTrace) => (error, stackTrace) =>
log.severe("Error logging out $userEmail", error, stackTrace), log.severe("Logout failed for $userEmail", error, stackTrace),
); );
await Future.wait([ await Future.wait([
@ -129,8 +129,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
shouldChangePassword: false, shouldChangePassword: false,
isAuthenticated: false, isAuthenticated: false,
); );
} catch (e) { } catch (e, stack) {
log.severe("Error logging out $e"); log.severe('Logout failed', e, stack);
} }
} }

View file

@ -36,7 +36,7 @@ class OAuthService {
), ),
); );
} catch (e, stack) { } catch (e, stack) {
log.severe("Error performing oAuthLogin: ${e.toString()}", e, stack); log.severe("OAuth login failed", e, stack);
return null; return null;
} }
} }

View file

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; 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/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.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), lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
); );
_log.severe( _log.severe(
"Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}", "Cannot fetch map light style",
lightResponse.toLoggerString(),
); );
return; return;
} }
@ -77,9 +79,7 @@ class MapStateNotifier extends _$MapStateNotifier {
state = state.copyWith( state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
); );
_log.severe( _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
"Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}",
);
return; return;
} }

View file

@ -28,6 +28,7 @@ class MapSerivce with ErrorLoggerMixin {
return markers?.map(MapMarker.fromDto) ?? []; return markers?.map(MapMarker.fromDto) ?? [];
}, },
defaultValue: [], defaultValue: [],
errorMessage: "Failed to get map markers",
); );
} }
} }

View file

@ -105,10 +105,8 @@ class MapUtils {
timeLimit: const Duration(seconds: 5), timeLimit: const Duration(seconds: 5),
); );
return (currentUserLocation, null); return (currentUserLocation, null);
} catch (error) { } catch (error, stack) {
_log.severe( _log.severe("Cannot get user's current location", error, stack);
"Cannot get user's current location due to ${error.toString()}",
);
return (null, LocationPermission.unableToDetermine); return (null, LocationPermission.unableToDetermine);
} }
} }

View file

@ -147,7 +147,7 @@ class MapAssetGrid extends HookConsumerWidget {
}, },
error: (error, stackTrace) { error: (error, stackTrace) {
log.warning( log.warning(
"Cannot get assets in the current map bounds $error", "Cannot get assets in the current map bounds",
error, error,
stackTrace, stackTrace,
); );

View file

@ -47,7 +47,7 @@ class MemoryService {
return memories.isNotEmpty ? memories : null; return memories.isNotEmpty ? memories : null;
} catch (error, stack) { } catch (error, stack) {
log.severe("Cannot get memories ${error.toString()}", error, stack); log.severe("Cannot get memories", error, stack);
return null; return null;
} }
} }

View file

@ -55,9 +55,9 @@ class MemoryCard extends StatelessWidget {
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Determine the fit using the aspect ratio // Determine the fit using the aspect ratio
BoxFit fit = BoxFit.fitWidth; BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) { if (asset.width != null && asset.height != null) {
final aspectRatio = asset.height! / asset.width!; final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio = final phoneAspectRatio =
constraints.maxWidth / constraints.maxHeight; constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction // Look for a 25% difference in either direction

View file

@ -40,7 +40,7 @@ class PartnerService {
return userDtos.map((u) => User.fromPartnerDto(u)).toList(); return userDtos.map((u) => User.fromPartnerDto(u)).toList();
} }
} catch (e) { } 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; return null;
} }
@ -51,7 +51,7 @@ class PartnerService {
partner.isPartnerSharedBy = false; partner.isPartnerSharedBy = false;
await _db.writeTxn(() => _db.users.put(partner)); await _db.writeTxn(() => _db.users.put(partner));
} catch (e) { } catch (e) {
_log.warning("failed to remove partner ${partner.id}:\n$e"); _log.warning("Failed to remove partner ${partner.id}", e);
return false; return false;
} }
return true; return true;
@ -66,7 +66,7 @@ class PartnerService {
return true; return true;
} }
} catch (e) { } catch (e) {
_log.warning("failed to add partner ${partner.id}:\n$e"); _log.warning("Failed to add partner ${partner.id}", e);
} }
return false; return false;
} }
@ -81,7 +81,7 @@ class PartnerService {
return true; return true;
} }
} catch (e) { } catch (e) {
_log.warning("failed to update partner ${partner.id}:\n$e"); _log.warning("Failed to update partner ${partner.id}", e);
} }
return false; return false;
} }

View file

@ -22,7 +22,7 @@ class SharedLinkService {
? AsyncData(list.map(SharedLink.fromDto).toList()) ? AsyncData(list.map(SharedLink.fromDto).toList())
: const AsyncData([]); : const AsyncData([]);
} catch (e, stack) { } catch (e, stack) {
_log.severe("failed to fetch shared links - $e"); _log.severe("Failed to fetch shared links", e, stack);
return AsyncError(e, stack); return AsyncError(e, stack);
} }
} }
@ -31,7 +31,7 @@ class SharedLinkService {
try { try {
return await _apiService.sharedLinkApi.removeSharedLink(id); return await _apiService.sharedLinkApi.removeSharedLink(id);
} catch (e) { } 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) { } catch (e) {
_log.severe("failed to create shared link with error - $e"); _log.severe("Failed to create shared link", e);
} }
return null; return null;
} }
@ -113,7 +113,7 @@ class SharedLinkService {
return SharedLink.fromDto(responseDto); return SharedLink.fromDto(responseDto);
} }
} catch (e) { } 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; return null;
} }

View file

@ -44,7 +44,7 @@ class TrashNotifier extends StateNotifier<bool> {
.read(syncServiceProvider) .read(syncServiceProvider)
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList()); .handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());
} catch (error, stack) { } 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<bool> {
return isRemoved; return isRemoved;
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack); _log.severe("Cannot remove assets", error, stack);
} }
return false; return false;
} }
@ -93,7 +93,7 @@ class TrashNotifier extends StateNotifier<bool> {
return true; return true;
} }
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore assets", error, stack);
} }
return false; return false;
} }
@ -123,7 +123,7 @@ class TrashNotifier extends StateNotifier<bool> {
await _db.assets.putAll(updatedAssets); await _db.assets.putAll(updatedAssets);
}); });
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore trash", error, stack);
} }
} }
} }

View file

@ -25,7 +25,7 @@ class TrashService {
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds)); await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds));
return true; return true;
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore assets ${error.toString()}", error, stack); _log.severe("Cannot restore assets", error, stack);
return false; return false;
} }
} }
@ -34,7 +34,7 @@ class TrashService {
try { try {
await _apiService.trashApi.emptyTrash(); await _apiService.trashApi.emptyTrash();
} catch (error, stack) { } 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 { try {
await _apiService.trashApi.restoreTrash(); await _apiService.trashApi.restoreTrash();
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore trash", error, stack);
} }
} }
} }

View file

@ -1,8 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/routing/router.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:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -16,28 +16,31 @@ class AuthGuard extends AutoRouteGuard {
resolver.next(true); resolver.next(true);
try { 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 (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login // 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()]); 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) { } on ApiException catch (e) {
if (e.code == HttpStatus.badRequest && // On an unauthorized request, take us to the login page
e.innerException is SocketException) { if (e.code == HttpStatus.unauthorized) {
// offline? _log.warning("Unauthorized access token.");
_log.fine(
"Unable to validate user token. User may be offline and offline browsing is allowed.",
);
} else {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]); router.replaceAll([const LoginRoute()]);
return; return;
} }
} catch (e) { } catch (e) {
debugPrint("Error [onNavigation] ${e.toString()}"); // Otherwise, this is not fatal, but we still log the warning
router.replaceAll([const LoginRoute()]); _log.warning('Error validating access token from server: $e');
return;
} }
} }
} }

View file

@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
void Function()? onPaused, void Function()? onPaused,
Widget? placeholder, Widget? placeholder,
bool showControls = true, bool showControls = true,
Duration hideControlsTimer = const Duration(seconds: 5), Duration hideControlsTimer = const Duration(milliseconds: 1500),
bool showDownloadingIndicator = true, bool showDownloadingIndicator = true,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused, this.onPaused,
this.placeholder, this.placeholder,
this.showControls = true, this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5), this.hideControlsTimer = const Duration(milliseconds: 1500),
this.showDownloadingIndicator = true, this.showDownloadingIndicator = true,
}); });

View file

@ -175,6 +175,11 @@ class Asset {
int? stackCount; 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 /// `true` if this [Asset] is present on the device
@ignore @ignore
bool get isLocal => localId != null; bool get isLocal => localId != null;

View file

@ -9,6 +9,7 @@ part 'logger_message.model.g.dart';
class LoggerMessage { class LoggerMessage {
Id id = Isar.autoIncrement; Id id = Isar.autoIncrement;
String message; String message;
String? details;
@Enumerated(EnumType.ordinal) @Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO; LogLevel level = LogLevel.INFO;
DateTime createdAt; DateTime createdAt;
@ -17,6 +18,7 @@ class LoggerMessage {
LoggerMessage({ LoggerMessage({
required this.message, required this.message,
required this.details,
required this.level, required this.level,
required this.createdAt, required this.createdAt,
required this.context1, required this.context1,

View file

@ -90,7 +90,7 @@ class AssetService {
return allAssets; return allAssets;
} catch (error, stack) { } catch (error, stack) {
log.severe( log.severe(
'Error while getting remote assets: ${error.toString()}', 'Error while getting remote assets',
error, error,
stack, stack,
); );
@ -117,7 +117,7 @@ class AssetService {
); );
return true; return true;
} catch (error, stack) { } catch (error, stack) {
log.severe("Error deleteAssets ${error.toString()}", error, stack); log.severe("Error while deleting assets", error, stack);
} }
return false; return false;
} }

View file

@ -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. /// [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 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. /// in the class.
/// ///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// 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}'); debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
final lm = LoggerMessage( final lm = LoggerMessage(
message: record.message, message: record.message,
details: record.error?.toString(),
level: record.level.toLogLevel(), level: record.level.toLogLevel(),
createdAt: record.time, createdAt: record.time,
context1: record.loggerName, context1: record.loggerName,

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -41,7 +42,8 @@ class ShareService {
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe( _log.severe(
"Asset download failed with status - ${res.statusCode} and response - ${res.body}", "Asset download for ${asset.fileName} failed",
res.toLoggerString(),
); );
continue; continue;
} }
@ -68,7 +70,7 @@ class ShareService {
); );
return true; return true;
} catch (error) { } catch (error) {
_log.severe("Share failed with error $error"); _log.severe("Share failed", error);
} }
return false; return false;
} }

View file

@ -140,7 +140,7 @@ class SyncService {
try { try {
await _db.writeTxn(() => a.put(_db)); await _db.writeTxn(() => a.put(_db));
} on IsarError catch (e) { } 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 false;
} }
return true; return true;
@ -173,7 +173,7 @@ class SyncService {
} }
return false; return false;
} on IsarError catch (e) { } 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; return null;
} }
@ -232,7 +232,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await upsertAssetsWithExif(toAdd + toUpdate); await upsertAssetsWithExif(toAdd + toUpdate);
} on IsarError catch (e) { } 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); await _updateUserAssetsETag(user, now);
return true; return true;
@ -364,7 +364,7 @@ class SyncService {
}); });
_log.info("Synced changes of remote album ${album.name} to DB"); _log.info("Synced changes of remote album ${album.name} to DB");
} on IsarError catch (e) { } 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) { if (album.shared || dto.shared) {
@ -441,7 +441,7 @@ class SyncService {
assert(ok); assert(ok);
_log.info("Removed local album $album from DB"); _log.info("Removed local album $album from DB");
} catch (e) { } 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"); _log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e) { } 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; return true;
@ -623,7 +623,7 @@ class SyncService {
}); });
_log.info("Fast synced local album ${ape.name} to DB"); _log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) { } 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; return false;
} }
@ -656,7 +656,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}"); _log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e) { } 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"); _log.info("Upserted ${assets.length} assets into the DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe( _log.severe("Failed to upsert ${assets.length} assets into the DB", e);
"Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
);
// give details on the errors // give details on the errors
assets.sort(Asset.compareByOwnerChecksum); assets.sort(Asset.compareByOwnerChecksum);
final inDb = await _db.assets.getAllByOwnerIdChecksum( final inDb = await _db.assets.getAllByOwnerIdChecksum(
@ -776,7 +774,7 @@ class SyncService {
}); });
return true; return true;
} catch (e) { } 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; return false;
} }
} }

View file

@ -42,7 +42,7 @@ class UserService {
final dto = await _apiService.userApi.getAllUsers(isAll); final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromUserDto).toList(); return dto?.map(User.fromUserDto).toList();
} catch (e) { } catch (e) {
_log.warning("Failed get all users:\n$e"); _log.warning("Failed get all users", e);
return null; return null;
} }
} }
@ -65,7 +65,7 @@ class UserService {
), ),
); );
} catch (e) { } catch (e) {
_log.warning("Failed to upload profile image:\n$e"); _log.warning("Failed to upload profile image", e);
return null; return null;
} }
} }

View file

@ -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'));
},
),
);
}
}

View file

@ -15,7 +15,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var isDarkTheme = context.isDarkTheme; var isDarkTheme = context.isDarkTheme;
buildStackMessage(String stackTrace) { buildTextWithCopyButton(String header, String text) {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.only(bottom: 8.0),
child: Text( child: Text(
"STACK TRACES", header,
style: TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
color: context.primaryColor, color: context.primaryColor,
@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget {
), ),
IconButton( IconButton(
onPressed: () { onPressed: () {
Clipboard.setData(ClipboardData(text: stackTrace)) Clipboard.setData(ClipboardData(text: text)).then((_) {
.then((_) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: SelectableText( child: SelectableText(
stackTrace, text,
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,
style: const TextStyle( style: const TextStyle(
fontSize: 12.0, fontSize: 12.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget {
body: SafeArea( body: SafeArea(
child: ListView( child: ListView(
children: [ children: [
buildLogMessage(logMessage.message), buildTextWithCopyButton("MESSAGE", logMessage.message),
if (logMessage.details != null)
buildTextWithCopyButton("DETAILS", logMessage.details.toString()),
if (logMessage.context1 != null) if (logMessage.context1 != null)
buildLogContext1(logMessage.context1.toString()), buildLogContext1(logMessage.context1.toString()),
if (logMessage.context2 != null) if (logMessage.context2 != null)
buildStackMessage(logMessage.context2.toString()), buildTextWithCopyButton(
"STACK TRACE",
logMessage.context2.toString(),
),
], ],
), ),
), ),

View file

@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: const Text(
"Logs - ${logMessages.value.length}", "Logs",
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16.0, fontSize: 16.0,
), ),
@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget {
dense: true, dense: true,
tileColor: getTileColor(logMessage.level), tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10, minLeadingWidth: 10,
title: Text.rich( title: Text(
TextSpan( truncateLogMessage(logMessage.message, 4),
children: [ style: const TextStyle(
TextSpan( fontSize: 14.0,
text: "#$index ", fontFamily: "Inconsolata",
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,
),
),
],
), ),
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
), ),
subtitle: Text( 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( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
color: Colors.grey[600], color: Colors.grey[600],

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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( final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square( builder: (context) => SizedBox.square(
@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox( child: DecoratedBox(
decoration: decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)), 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<ValueNotifier<bool>> {
class _LoadingOverlayState class _LoadingOverlayState
extends HookState<ValueNotifier<bool>, _LoadingOverlay> { extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
late final _isProcessing = ValueNotifier(false)..addListener(_listener); late final _isLoading = ValueNotifier(false)..addListener(_listener);
OverlayEntry? overlayEntry; OverlayEntry? _loadingOverlay;
void _listener() { void _listener() {
setState(() { setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isProcessing.value) { if (_isLoading.value) {
overlayEntry?.remove(); _loadingOverlay?.remove();
overlayEntry = _loadingEntry; _loadingOverlay = _loadingEntry;
Overlay.of(context).insert(_loadingEntry); Overlay.of(context).insert(_loadingEntry);
} else { } else {
overlayEntry?.remove(); _loadingOverlay?.remove();
overlayEntry = null; _loadingOverlay = null;
} }
}); });
}); });
@ -47,17 +52,17 @@ class _LoadingOverlayState
@override @override
ValueNotifier<bool> build(BuildContext context) { ValueNotifier<bool> build(BuildContext context) {
return _isProcessing; return _isLoading;
} }
@override @override
void dispose() { void dispose() {
_isProcessing.dispose(); _isLoading.dispose();
super.dispose(); super.dispose();
} }
@override @override
Object? get debugValue => _isProcessing.value; Object? get debugValue => _isLoading.value;
@override @override
String get debugLabel => 'useProcessingOverlay<>'; String get debugLabel => 'useProcessingOverlay<>';

View file

@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget {
deviceIsOffline = true; deviceIsOffline = true;
log.fine("Device seems to be offline upon launch"); log.fine("Device seems to be offline upon launch");
} else { } else {
log.severe(e); log.severe("Failed to resolve endpoint", e);
} }
} catch (e) { } catch (e) {
log.severe(e); log.severe("Failed to resolve endpoint", e);
} }
try { try {
@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget {
ref.read(authenticationProvider.notifier).logout(); ref.read(authenticationProvider.notifier).logout();
log.severe( log.severe(
'Cannot set success login info: $error', 'Cannot set success login info',
error, error,
stackTrace, stackTrace,
); );

View file

@ -108,6 +108,7 @@ doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md doc/PersonUpdateDto.md
doc/PersonWithFacesResponseDto.md doc/PersonWithFacesResponseDto.md
doc/PlacesResponseDto.md
doc/QueueStatusDto.md doc/QueueStatusDto.md
doc/ReactionLevel.md doc/ReactionLevel.md
doc/ReactionType.md doc/ReactionType.md
@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart lib/model/person_update_dto.dart
lib/model/person_with_faces_response_dto.dart lib/model/person_with_faces_response_dto.dart
lib/model/places_response_dto.dart
lib/model/queue_status_dto.dart lib/model/queue_status_dto.dart
lib/model/reaction_level.dart lib/model/reaction_level.dart
lib/model/reaction_type.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_statistics_response_dto_test.dart
test/person_update_dto_test.dart test/person_update_dto_test.dart
test/person_with_faces_response_dto_test.dart test/person_with_faces_response_dto_test.dart
test/places_response_dto_test.dart
test/queue_status_dto_test.dart test/queue_status_dto_test.dart
test/reaction_level_test.dart test/reaction_level_test.dart
test/reaction_type_test.dart test/reaction_type_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/PlacesResponseDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -413,10 +413,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file name: file
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.4" version: "7.0.0"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:
@ -569,10 +569,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_udid name: flutter_udid
sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84" sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "3.0.0"
flutter_web_auth: flutter_web_auth:
dependency: "direct main" dependency: "direct main"
description: description:
@ -619,10 +619,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: geolocator name: geolocator
sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02 sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.1.0" version: "11.0.0"
geolocator_android: geolocator_android:
dependency: transitive dependency: transitive
description: description:
@ -651,10 +651,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_web name: geolocator_web
sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58" sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "3.0.0"
geolocator_windows: geolocator_windows:
dependency: transitive dependency: transitive
description: description:
@ -860,6 +860,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.8.1" 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: lints:
dependency: transitive dependency: transitive
description: description:
@ -907,18 +931,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16" version: "0.12.16+1"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.8.0"
meta: meta:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -1002,10 +1026,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path name: path
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.3" version: "1.9.0"
path_provider: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1138,10 +1162,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.4"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1170,10 +1194,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: process name: process
sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.4" version: "5.0.2"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@ -1298,10 +1322,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_linux name: shared_preferences_linux
sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.2"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1322,10 +1346,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_windows name: shared_preferences_windows
sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.2"
shelf: shelf:
dependency: transitive dependency: transitive
description: description:
@ -1639,10 +1663,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.10.0" version: "13.0.0"
wakelock_plus: wakelock_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1687,10 +1711,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webdriver name: webdriver
sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.3"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.95.0+122 version: 1.95.1+123
isar_version: &isar_version 3.1.0+1 isar_version: &isar_version 3.1.0+1
environment: environment:
@ -32,8 +32,8 @@ dependencies:
git: git:
url: https://github.com/maplibre/flutter-maplibre-gl.git url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
geolocator: ^10.1.0 # used to move to current location in map view geolocator: ^11.0.0 # used to move to current location in map view
flutter_udid: ^2.1.1 flutter_udid: ^3.0.0
package_info_plus: ^5.0.1 package_info_plus: ^5.0.1
url_launcher: ^6.2.4 url_launcher: ^6.2.4
http: 0.13.5 http: 0.13.5

View file

@ -2463,6 +2463,7 @@
"required": false, "required": false,
"in": "query", "in": "query",
"schema": { "schema": {
"default": false,
"type": "boolean" "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": { "/search/smart": {
"post": { "post": {
"operationId": "searchSmart", "operationId": "searchSmart",
@ -6413,7 +6458,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.95.0", "version": "1.95.1",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@ -8429,6 +8474,7 @@
"type": "string" "type": "string"
}, },
"withArchived": { "withArchived": {
"default": false,
"type": "boolean" "type": "boolean"
}, },
"withDeleted": { "withDeleted": {
@ -8591,6 +8637,9 @@
}, },
"PeopleResponseDto": { "PeopleResponseDto": {
"properties": { "properties": {
"hidden": {
"type": "integer"
},
"people": { "people": {
"items": { "items": {
"$ref": "#/components/schemas/PersonResponseDto" "$ref": "#/components/schemas/PersonResponseDto"
@ -8602,6 +8651,7 @@
} }
}, },
"required": [ "required": [
"hidden",
"people", "people",
"total" "total"
], ],
@ -8750,6 +8800,31 @@
], ],
"type": "object" "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": { "QueueStatusDto": {
"properties": { "properties": {
"isActive": { "isActive": {
@ -9435,6 +9510,9 @@
"isMotion": { "isMotion": {
"type": "boolean" "type": "boolean"
}, },
"isNotInAlbum": {
"type": "boolean"
},
"isOffline": { "isOffline": {
"type": "boolean" "type": "boolean"
}, },
@ -9497,6 +9575,7 @@
"type": "string" "type": "string"
}, },
"withArchived": { "withArchived": {
"default": false,
"type": "boolean" "type": "boolean"
}, },
"withDeleted": { "withDeleted": {

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * 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 * @interface PeopleResponseDto
*/ */
export interface PeopleResponseDto { export interface PeopleResponseDto {
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'hidden': number;
/** /**
* *
* @type {Array<PersonResponseDto>} * @type {Array<PersonResponseDto>}
@ -2988,6 +2994,43 @@ export interface PersonWithFacesResponseDto {
*/ */
'thumbnailPath': string; '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 * @export
@ -3880,6 +3923,12 @@ export interface SmartSearchDto {
* @memberof SmartSearchDto * @memberof SmartSearchDto
*/ */
'isMotion'?: boolean; 'isMotion'?: boolean;
/**
*
* @type {boolean}
* @memberof SmartSearchDto
*/
'isNotInAlbum'?: boolean;
/** /**
* *
* @type {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<RequestArgs> => {
// 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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -15572,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url; const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); 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<Array<PlacesResponseDto>>> {
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 * @param {SmartSearchDto} smartSearchDto
@ -15639,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); 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<Array<PlacesResponseDto>> {
return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters. * @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
@ -15805,6 +15920,20 @@ export interface SearchApiSearchPersonRequest {
readonly withHidden?: boolean 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. * Request parameters for searchSmart operation in SearchApi.
* @export * @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)); 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. * @param {SearchApiSearchSmartRequest} requestParameters Request parameters.

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

Binary file not shown.

View file

@ -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;
}

View file

@ -1 +1,2 @@
export * from './fetch-client'; export * from './fetch-client';
export * from './fetch-errors';

View file

@ -29,9 +29,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"

View file

@ -1,5 +1,5 @@
# dev build # 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 RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
@ -40,7 +40,7 @@ RUN npm run build
# prod 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 WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \

View file

@ -50,6 +50,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let asset3: AssetResponseDto; let asset3: AssetResponseDto;
let asset4: AssetResponseDto; let asset4: AssetResponseDto;
let asset5: AssetResponseDto; let asset5: AssetResponseDto;
let asset6: AssetResponseDto;
const createAsset = async ( const createAsset = async (
loginResponse: LoginResponseDto, loginResponse: LoginResponseDto,
@ -96,12 +97,11 @@ describe(`${AssetController.name} (e2e)`, () => {
beforeEach(async () => { beforeEach(async () => {
await testApp.reset({ entities: [AssetEntity, AssetStackEntity] }); 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-01-01')),
createAsset(user1, new Date('1970-02-10')), createAsset(user1, new Date('1970-02-10')),
createAsset(user1, new Date('1970-02-11'), { createAsset(user1, new Date('1970-02-11'), {
isFavorite: true, isFavorite: true,
isArchived: true,
isExternal: true, isExternal: true,
isReadOnly: true, isReadOnly: true,
type: AssetType.VIDEO, type: AssetType.VIDEO,
@ -118,6 +118,9 @@ describe(`${AssetController.name} (e2e)`, () => {
createAsset(user1, new Date('1970-01-01'), { createAsset(user1, new Date('1970-01-01'), {
deletedAt: yesterday.toJSDate(), deletedAt: yesterday.toJSDate(),
}), }),
createAsset(user1, new Date('1970-02-11'), {
isArchived: true,
}),
]); ]);
await assetRepository.upsertExif({ await assetRepository.upsertExif({
@ -275,14 +278,14 @@ describe(`${AssetController.name} (e2e)`, () => {
should: 'should search by isArchived (true)', should: 'should search by isArchived (true)',
deferred: () => ({ deferred: () => ({
query: { isArchived: true }, query: { isArchived: true },
assets: [asset3], assets: [asset6],
}), }),
}, },
{ {
should: 'should search by isArchived (false)', should: 'should search by isArchived (false)',
deferred: () => ({ deferred: () => ({
query: { isArchived: false }, query: { isArchived: false },
assets: [asset2, asset1], assets: [asset3, asset2, asset1],
}), }),
}, },
{ {
@ -313,6 +316,20 @@ describe(`${AssetController.name} (e2e)`, () => {
assets: [asset3], 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', should: 'should search by createdBefore',
deferred: () => ({ deferred: () => ({
@ -514,8 +531,8 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body.length).toBe(assets.length); expect(body.length).toBe(assets.length);
for (let i = 0; i < assets.length; i++) { for (const [i, asset] of assets.entries()) {
expect(body[i]).toEqual(expect.objectContaining({ id: assets[i].id })); 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 () => { it("should not upload to another user's library", async () => {
const content = randomBytes(32); 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 }); await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
const { body, status } = await request(server) const { body, status } = await request(server)
@ -902,7 +919,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.get('/asset/statistics') .get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`); .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); expect(status).toBe(200);
}); });
@ -923,7 +940,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isArchived: true }); .query({ isArchived: true });
expect(status).toBe(200); 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 () => { it('should return stats of all favored and archived assets', async () => {
@ -933,7 +950,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isFavorite: true, isArchived: true }); .query({ isFavorite: true, isArchived: true });
expect(status).toBe(200); 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 () => { it('should return stats of all assets neither favored nor archived', async () => {
@ -1041,7 +1058,7 @@ describe(`${AssetController.name} (e2e)`, () => {
expect.arrayContaining([ expect.arrayContaining([
{ count: 1, timeBucket: '2023-11-01T00:00:00.000Z' }, { count: 1, timeBucket: '2023-11-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-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}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(2);
expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset2.id })])); expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: asset2.id }),
expect.objectContaining({ id: asset3.id }),
]),
);
}); });
it('should get all map markers', async () => { it('should get all map markers', async () => {
@ -1209,8 +1231,13 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isArchived: false }); .query({ isArchived: false });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(2);
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]); expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: asset2.id }),
expect.objectContaining({ id: asset3.id }),
]),
);
}); });
}); });

View file

@ -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>(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 });
});
});
});

View file

@ -44,7 +44,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (exif)', () => { describe('GET /search (exif)', () => {
beforeEach(async () => { 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 assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
@ -166,7 +166,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (smart info)', () => { describe('GET /search (smart info)', () => {
beforeEach(async () => { 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 assetRepository.upsertExif({ assetId, ...searchStub.exif });
await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random)); await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
@ -215,7 +215,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (file name)', () => { describe('GET /search (file name)', () => {
beforeEach(async () => { 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 assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });

View file

@ -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);
},
};

View file

@ -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[];
},
};

View file

@ -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;
},
};

Some files were not shown because too many files have changed in this diff Show more