Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f53e83d49 | ||
|
|
b1a896ba61 | ||
|
|
d28abaad7b | ||
|
|
79442fc8a1 | ||
|
|
93f0a866a3 | ||
|
|
84fe41df31 | ||
|
|
e4f32a045d | ||
|
|
784d92dbb3 | ||
|
|
c88184673a | ||
|
|
74d431f881 | ||
|
|
e2c0945bc1 | ||
|
|
a02a24f349 | ||
|
|
87a7825cbc | ||
|
|
f0ea99cea9 | ||
|
|
0d2a656aa1 | ||
|
|
6d91c23f65 | ||
|
|
df02a9f5ed | ||
|
|
2702bcc407 | ||
|
|
807cd245f4 | ||
|
|
dc0f8756f5 | ||
|
|
df9ab8943d | ||
|
|
79409438a7 | ||
|
|
b15eec7ca7 | ||
|
|
908104299d | ||
|
|
c94874296c | ||
|
|
8361130351 | ||
|
|
907a95a746 | ||
|
|
57f25855d3 | ||
|
|
e02964ca0d | ||
|
|
fd301a3261 | ||
|
|
d76baee50d | ||
|
|
5e485e35e9 | ||
|
|
cfb49c8be0 | ||
|
|
2f121af9ec | ||
|
|
21feb69083 | ||
|
|
fb18129843 | ||
|
|
9fa2424652 | ||
|
|
7d1edddd51 | ||
|
|
3f18a936b2 | ||
|
|
e20d3048cd | ||
|
|
8965c25f54 | ||
|
|
7e18e69c1c | ||
|
|
d799bf7910 | ||
|
|
4272b496ff | ||
|
|
99a002b947 | ||
|
|
c8bdeb8fec | ||
|
|
8a05ff51e9 | ||
|
|
3e8af16270 | ||
|
|
45ecb629a1 | ||
|
|
943105ea20 | ||
|
|
2a75f884d9 | ||
|
|
912d723281 | ||
|
|
038e69fd02 | ||
|
|
9d3ed719e0 | ||
|
|
6ec4c5874b | ||
|
|
57758293e5 | ||
|
|
bc3979029d | ||
|
|
878932f87e | ||
|
|
a2934b8830 | ||
|
|
719dbcc4d0 | ||
|
|
cc6de7d1f1 | ||
|
|
78ece4ced9 | ||
|
|
ee58569b42 | ||
|
|
07d747221e | ||
|
|
3cd3411c1f | ||
|
|
b3b6426695 | ||
|
|
6bb30291de | ||
|
|
2c9dd18f1b | ||
|
|
07ef008b40 | ||
|
|
b3131dfe14 | ||
|
|
b4e924b0c0 | ||
|
|
01d6707b59 | ||
|
|
a224bb23d0 | ||
|
|
75947ab6c2 | ||
|
|
e3cccba78c | ||
|
|
ec55acc98c | ||
|
|
869e9f1399 | ||
|
|
46f85618db | ||
|
|
749b182f97 | ||
|
|
2ebb57cbd4 | ||
|
|
5c0c98473d | ||
|
|
546edc2e91 | ||
|
|
173b47033a | ||
|
|
d3e14fd662 | ||
|
|
06c134950a | ||
|
|
8f57bfb496 | ||
|
|
855aa8e30a | ||
|
|
f798e037d5 | ||
|
|
a1bc74cdd6 | ||
|
|
aeb7081af1 | ||
|
|
c5da317033 | ||
|
|
01f682134a | ||
|
|
43f887e5f2 | ||
|
|
ee3b3ca115 | ||
|
|
e7995014f9 | ||
|
|
a771f33fa3 | ||
|
|
397570ad1a |
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
uses: docker/setup-buildx-action@v3.1.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
uses: docker/setup-buildx-action@v3.1.0
|
||||
# Workaround to fix error:
|
||||
# failed to push: failed to copy: io: read/write on closed pipe
|
||||
# See https://github.com/docker/build-push-action/issues/761
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -12,7 +12,7 @@ concurrency:
|
||||
jobs:
|
||||
server-e2e-api:
|
||||
name: Server (e2e-api)
|
||||
runs-on: mich
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
server-e2e-jobs:
|
||||
name: Server (e2e-jobs)
|
||||
runs-on: mich
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -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">
|
||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||
</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>
|
||||
|
||||
818
cli/package-lock.json
generated
818
cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ x-server-build: &server-common
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
command: [ "./start-server.sh" ]
|
||||
command: [ "start.sh", "immich" ]
|
||||
<<: *server-common
|
||||
ports:
|
||||
- 2283:3001
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
command: [ "./start-microservices.sh" ]
|
||||
command: [ "start.sh", "microservices" ]
|
||||
<<: *server-common
|
||||
# extends:
|
||||
# file: hwaccel.transcoding.yml
|
||||
|
||||
@@ -60,12 +60,12 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
||||
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||
image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
|
||||
@@ -38,12 +38,6 @@ services:
|
||||
- /dev/dri:/dev/dri
|
||||
- /dev/dma_heap:/dev/dma_heap
|
||||
- /dev/mpp_service:/dev/mpp_service
|
||||
volumes:
|
||||
- /usr/bin/ffmpeg:/usr/bin/ffmpeg_mpp:ro
|
||||
- /lib/aarch64-linux-gnu:/lib/ffmpeg-mpp:ro
|
||||
- /lib/aarch64-linux-gnu/libblas.so.3:/lib/ffmpeg-mpp/libblas.so.3:ro # symlink is resolved by mounting
|
||||
- /lib/aarch64-linux-gnu/liblapack.so.3:/lib/ffmpeg-mpp/liblapack.so.3:ro # symlink is resolved by mounting
|
||||
- /lib/aarch64-linux-gnu/pulseaudio/libpulsecommon-15.99.so:/lib/ffmpeg-mpp/libpulsecommon-15.99.so:ro
|
||||
|
||||
vaapi:
|
||||
devices:
|
||||
|
||||
@@ -44,22 +44,13 @@ Below is an example config for Apache2 site configuration.
|
||||
|
||||
```
|
||||
<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>
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@@ -88,10 +88,7 @@ Some basic examples:
|
||||
|
||||
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button.
|
||||
|
||||
If your photos are on a network drive you will likely have to enable filesystem polling. The performance hit for polling large libraries is currently unknown, feel free to test this feature and report back. In addition to the boolean feature flag, the configuration file allows customization of the following parameters, please see the [chokidar documentation](https://github.com/paulmillr/chokidar?tab=readme-ov-file#performance) for reference.
|
||||
|
||||
- `usePolling` (default: `false`).
|
||||
- `interval`. (default: 10000). When using polling, this is how often (in milliseconds) the filesystem is polled.
|
||||
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
|
||||
|
||||
### Nightly job
|
||||
|
||||
|
||||
@@ -50,12 +50,22 @@ import {
|
||||
mdiVectorCombine,
|
||||
mdiVideo,
|
||||
mdiWeb,
|
||||
mdiScaleBalance,
|
||||
} from '@mdi/js';
|
||||
import Layout from '@theme/Layout';
|
||||
import React from 'react';
|
||||
import Timeline, { DateType, Item } from '../components/timeline';
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
icon: mdiScaleBalance,
|
||||
description: 'Immich switches to AGPLv3 license',
|
||||
title: 'AGPL License',
|
||||
release: 'v1.95.0',
|
||||
tag: 'v1.95.0',
|
||||
date: new Date(2024, 1, 20),
|
||||
dateType: DateType.RELEASE,
|
||||
},
|
||||
{
|
||||
icon: mdiEyeRefreshOutline,
|
||||
description: 'Automatically import files in external libraries when the operating system detects changes.',
|
||||
|
||||
@@ -22,14 +22,16 @@ x-server-build: &server-common
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich-e2e-server
|
||||
command: [ "./start.sh", "immich" ]
|
||||
<<: *server-common
|
||||
ports:
|
||||
- 2283:3001
|
||||
|
||||
# immich-microservices:
|
||||
# command: [ "./start.sh", "microservices" ]
|
||||
# <<: *server-common
|
||||
immich-microservices:
|
||||
container_name: immich-e2e-microservices
|
||||
command: [ "./start.sh", "microservices" ]
|
||||
<<: *server-common
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
||||
|
||||
255
e2e/package-lock.json
generated
255
e2e/package-lock.json
generated
@@ -12,20 +12,28 @@
|
||||
"@immich/cli": "file:../cli",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^1.3.0",
|
||||
"exiftool-vendored": "^24.5.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^6.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"../cli": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.0.8",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"bin": {
|
||||
"immich": "dist/index.js"
|
||||
},
|
||||
@@ -34,6 +42,7 @@
|
||||
"@testcontainers/postgresql": "^10.7.1",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^20.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
@@ -586,6 +595,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@photostructure/tz-lookup": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz",
|
||||
"integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.41.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
|
||||
@@ -776,6 +791,12 @@
|
||||
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
|
||||
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
@@ -794,6 +815,12 @@
|
||||
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
|
||||
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -801,9 +828,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.11.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
|
||||
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
|
||||
"version": "20.11.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
|
||||
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
@@ -899,9 +926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.0.tgz",
|
||||
"integrity": "sha512-e5Y5uK5NNoQMQaNitGQQjo9FoA5ZNcu7Bn6pH+dxUf48u6po1cX38kFBYUHZ9GNVkF4JLbncE0WeWwTw+nLrxg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz",
|
||||
"integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
@@ -922,17 +949,17 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "1.3.0"
|
||||
"vitest": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz",
|
||||
"integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
|
||||
"integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "1.3.0",
|
||||
"@vitest/utils": "1.3.0",
|
||||
"@vitest/spy": "1.3.1",
|
||||
"@vitest/utils": "1.3.1",
|
||||
"chai": "^4.3.10"
|
||||
},
|
||||
"funding": {
|
||||
@@ -940,12 +967,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz",
|
||||
"integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
|
||||
"integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "1.3.0",
|
||||
"@vitest/utils": "1.3.1",
|
||||
"p-limit": "^5.0.0",
|
||||
"pathe": "^1.1.1"
|
||||
},
|
||||
@@ -954,9 +981,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz",
|
||||
"integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
|
||||
"integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -968,9 +995,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz",
|
||||
"integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
|
||||
"integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tinyspy": "^2.2.0"
|
||||
@@ -980,9 +1007,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz",
|
||||
"integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
|
||||
"integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"diff-sequences": "^29.6.3",
|
||||
@@ -1054,6 +1081,15 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/batch-cluster": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
|
||||
"integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
@@ -1258,6 +1294,28 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz",
|
||||
"integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.11.0",
|
||||
"xmlhttprequest-ssl": "~2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz",
|
||||
"integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
|
||||
@@ -1349,6 +1407,43 @@
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/exiftool-vendored": {
|
||||
"version": "24.5.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz",
|
||||
"integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@photostructure/tz-lookup": "^9.0.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"batch-cluster": "^13.0.0",
|
||||
"he": "^1.2.0",
|
||||
"luxon": "^3.4.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"exiftool-vendored.exe": "12.76.0",
|
||||
"exiftool-vendored.pl": "12.76.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exiftool-vendored.exe": {
|
||||
"version": "12.76.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz",
|
||||
"integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/exiftool-vendored.pl": {
|
||||
"version": "12.76.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz",
|
||||
"integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"!win32"
|
||||
]
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
@@ -1542,6 +1637,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hexoid": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||
@@ -1699,6 +1803,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
|
||||
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.7",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",
|
||||
@@ -2341,6 +2454,34 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.4.tgz",
|
||||
"integrity": "sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -2546,9 +2687,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
|
||||
"integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
|
||||
"integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.19.3",
|
||||
@@ -2601,9 +2742,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz",
|
||||
"integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
|
||||
"integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
@@ -2637,16 +2778,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz",
|
||||
"integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
|
||||
"integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "1.3.0",
|
||||
"@vitest/runner": "1.3.0",
|
||||
"@vitest/snapshot": "1.3.0",
|
||||
"@vitest/spy": "1.3.0",
|
||||
"@vitest/utils": "1.3.0",
|
||||
"@vitest/expect": "1.3.1",
|
||||
"@vitest/runner": "1.3.1",
|
||||
"@vitest/snapshot": "1.3.1",
|
||||
"@vitest/spy": "1.3.1",
|
||||
"@vitest/utils": "1.3.1",
|
||||
"acorn-walk": "^8.3.2",
|
||||
"chai": "^4.3.10",
|
||||
"debug": "^4.3.4",
|
||||
@@ -2660,7 +2801,7 @@
|
||||
"tinybench": "^2.5.1",
|
||||
"tinypool": "^0.8.2",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "1.3.0",
|
||||
"vite-node": "1.3.1",
|
||||
"why-is-node-running": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2675,8 +2816,8 @@
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "1.3.0",
|
||||
"@vitest/ui": "1.3.0",
|
||||
"@vitest/browser": "1.3.1",
|
||||
"@vitest/ui": "1.3.1",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
@@ -2738,6 +2879,36 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
|
||||
"integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
|
||||
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
"@immich/cli": "file:../cli",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^1.3.0",
|
||||
"exiftool-vendored": "^24.5.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^6.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.3.0"
|
||||
|
||||
@@ -1,79 +1,94 @@
|
||||
import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain';
|
||||
import { ActivityController } from '@app/immich';
|
||||
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
|
||||
import { ActivityEntity } from '@app/infra/entities';
|
||||
import { errorStub, userDto, uuidStub } from '@test/fixtures';
|
||||
import {
|
||||
ActivityCreateDto,
|
||||
AlbumResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
LoginResponseDto,
|
||||
ReactionType,
|
||||
createActivity as create,
|
||||
createAlbum,
|
||||
} from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`${ActivityController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
describe('/activity', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let nonOwner: LoginResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let nonOwner: LoginResponseDto;
|
||||
|
||||
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
|
||||
create(
|
||||
{ activityCreateDto: dto },
|
||||
{ headers: asBearerAuth(accessToken || admin.accessToken) },
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
asset = await api.assetApi.upload(server, admin.accessToken, 'example');
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
|
||||
await api.userApi.create(server, admin.accessToken, userDto.user1);
|
||||
nonOwner = await api.authApi.login(server, userDto.user1);
|
||||
|
||||
album = await api.albumApi.create(server, admin.accessToken, {
|
||||
albumName: 'Album 1',
|
||||
assetIds: [asset.id],
|
||||
sharedWithUserIds: [nonOwner.userId],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
admin = await apiUtils.adminSetup();
|
||||
nonOwner = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
asset = await apiUtils.createAsset(admin.accessToken);
|
||||
album = await createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
albumName: 'Album 1',
|
||||
assetIds: [asset.id],
|
||||
sharedWithUserIds: [nonOwner.userId],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset({ entities: [ActivityEntity] });
|
||||
await dbUtils.reset(['activity']);
|
||||
});
|
||||
|
||||
describe('GET /activity', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get('/activity');
|
||||
const { status, body } = await request(app).get('/activity');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require an albumId', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid albumId', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: uuidStub.invalid })
|
||||
.query({ albumId: uuidDto.invalid })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid })
|
||||
.query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])),
|
||||
);
|
||||
});
|
||||
|
||||
it('should start off empty', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: album.id })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -82,22 +97,22 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should filter by album id', async () => {
|
||||
const album2 = await api.albumApi.create(server, admin.accessToken, {
|
||||
albumName: 'Album 2',
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
const album2 = await createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
albumName: 'Album 2',
|
||||
assetIds: [asset.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
|
||||
const [reaction] = await Promise.all([
|
||||
api.activityApi.create(server, admin.accessToken, {
|
||||
albumId: album.id,
|
||||
type: ReactionType.LIKE,
|
||||
}),
|
||||
api.activityApi.create(server, admin.accessToken, {
|
||||
albumId: album2.id,
|
||||
type: ReactionType.LIKE,
|
||||
}),
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
createActivity({ albumId: album2.id, type: ReactionType.Like }),
|
||||
]);
|
||||
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: album.id })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -108,15 +123,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
|
||||
it('should filter by type=comment', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
api.activityApi.create(server, admin.accessToken, {
|
||||
createActivity({
|
||||
albumId: album.id,
|
||||
type: ReactionType.COMMENT,
|
||||
type: ReactionType.Comment,
|
||||
comment: 'comment',
|
||||
}),
|
||||
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
]);
|
||||
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: album.id, type: 'comment' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -127,15 +142,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
|
||||
it('should filter by type=like', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
|
||||
api.activityApi.create(server, admin.accessToken, {
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
createActivity({
|
||||
albumId: album.id,
|
||||
type: ReactionType.COMMENT,
|
||||
type: ReactionType.Comment,
|
||||
comment: 'comment',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: album.id, type: 'like' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -146,18 +161,18 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
]);
|
||||
|
||||
const response1 = await request(server)
|
||||
const response1 = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: album.id, userId: uuidStub.notFound })
|
||||
.query({ albumId: album.id, userId: uuidDto.notFound })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(response1.status).toEqual(200);
|
||||
expect(response1.body.length).toBe(0);
|
||||
|
||||
const response2 = await request(server)
|
||||
const response2 = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: album.id, userId: admin.userId })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -169,15 +184,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
|
||||
it('should filter by assetId', async () => {
|
||||
const [reaction] = await Promise.all([
|
||||
api.activityApi.create(server, admin.accessToken, {
|
||||
createActivity({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.LIKE,
|
||||
type: ReactionType.Like,
|
||||
}),
|
||||
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
]);
|
||||
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/activity')
|
||||
.query({ albumId: album.id, assetId: asset.id })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -189,34 +204,45 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
|
||||
describe('POST /activity', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post('/activity');
|
||||
const { status, body } = await request(app).post('/activity');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require an albumId', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: uuidStub.invalid });
|
||||
.send({ albumId: uuidDto.invalid });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a comment when type is comment', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: uuidStub.notFound, type: 'comment', comment: null });
|
||||
.send({ albumId: uuidDto.notFound, type: 'comment', comment: null });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest(['comment must be a string', 'comment should not be empty']));
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'comment must be a string',
|
||||
'comment should not be empty',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a comment to an album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: album.id, type: 'comment', comment: 'This is my first comment' });
|
||||
.send({
|
||||
albumId: album.id,
|
||||
type: 'comment',
|
||||
comment: 'This is my first comment',
|
||||
});
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -229,7 +255,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should add a like to an album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: album.id, type: 'like' });
|
||||
@@ -245,11 +271,11 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should return a 200 for a duplicate like on the album', async () => {
|
||||
const reaction = await api.activityApi.create(server, admin.accessToken, {
|
||||
albumId: album.id,
|
||||
type: ReactionType.LIKE,
|
||||
});
|
||||
const { status, body } = await request(server)
|
||||
const [reaction] = await Promise.all([
|
||||
createActivity({ albumId: album.id, type: ReactionType.Like }),
|
||||
]);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: album.id, type: 'like' });
|
||||
@@ -258,12 +284,14 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should not confuse an album like with an asset like', async () => {
|
||||
const reaction = await api.activityApi.create(server, admin.accessToken, {
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.LIKE,
|
||||
});
|
||||
const { status, body } = await request(server)
|
||||
const [reaction] = await Promise.all([
|
||||
createActivity({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.Like,
|
||||
}),
|
||||
]);
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: album.id, type: 'like' });
|
||||
@@ -272,10 +300,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should add a comment to an asset', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: album.id, assetId: asset.id, type: 'comment', comment: 'This is my first comment' });
|
||||
.send({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: 'comment',
|
||||
comment: 'This is my first comment',
|
||||
});
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -288,7 +321,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should add a like to an asset', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
|
||||
@@ -304,12 +337,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should return a 200 for a duplicate like on an asset', async () => {
|
||||
const reaction = await api.activityApi.create(server, admin.accessToken, {
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.LIKE,
|
||||
});
|
||||
const { status, body } = await request(server)
|
||||
const [reaction] = await Promise.all([
|
||||
createActivity({
|
||||
albumId: album.id,
|
||||
assetId: asset.id,
|
||||
type: ReactionType.Like,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/activity')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
|
||||
@@ -320,50 +356,52 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
|
||||
describe('DELETE /activity/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).delete(`/activity/${uuidStub.notFound}`);
|
||||
const { status, body } = await request(app).delete(
|
||||
`/activity/${uuidDto.notFound}`,
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.delete(`/activity/${uuidStub.invalid}`)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/activity/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should remove a comment from an album', async () => {
|
||||
const reaction = await api.activityApi.create(server, admin.accessToken, {
|
||||
const reaction = await createActivity({
|
||||
albumId: album.id,
|
||||
type: ReactionType.COMMENT,
|
||||
type: ReactionType.Comment,
|
||||
comment: 'This is a test comment',
|
||||
});
|
||||
const { status } = await request(server)
|
||||
const { status } = await request(app)
|
||||
.delete(`/activity/${reaction.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(204);
|
||||
});
|
||||
|
||||
it('should remove a like from an album', async () => {
|
||||
const reaction = await api.activityApi.create(server, admin.accessToken, {
|
||||
const reaction = await createActivity({
|
||||
albumId: album.id,
|
||||
type: ReactionType.LIKE,
|
||||
type: ReactionType.Like,
|
||||
});
|
||||
const { status } = await request(server)
|
||||
const { status } = await request(app)
|
||||
.delete(`/activity/${reaction.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(204);
|
||||
});
|
||||
|
||||
it('should let the owner remove a comment by another user', async () => {
|
||||
const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
|
||||
const reaction = await createActivity({
|
||||
albumId: album.id,
|
||||
type: ReactionType.COMMENT,
|
||||
type: ReactionType.Comment,
|
||||
comment: 'This is a test comment',
|
||||
});
|
||||
|
||||
const { status } = await request(server)
|
||||
const { status } = await request(app)
|
||||
.delete(`/activity/${reaction.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
@@ -371,28 +409,33 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should not let a user remove a comment by another user', async () => {
|
||||
const reaction = await api.activityApi.create(server, admin.accessToken, {
|
||||
const reaction = await createActivity({
|
||||
albumId: album.id,
|
||||
type: ReactionType.COMMENT,
|
||||
type: ReactionType.Comment,
|
||||
comment: 'This is a test comment',
|
||||
});
|
||||
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/activity/${reaction.id}`)
|
||||
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('Not found or no activity.delete access'));
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest('Not found or no activity.delete access'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should let a non-owner remove their own comment', async () => {
|
||||
const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
|
||||
albumId: album.id,
|
||||
type: ReactionType.COMMENT,
|
||||
comment: 'This is a test comment',
|
||||
});
|
||||
const reaction = await createActivity(
|
||||
{
|
||||
albumId: album.id,
|
||||
type: ReactionType.Comment,
|
||||
comment: 'This is a test comment',
|
||||
},
|
||||
nonOwner.accessToken,
|
||||
);
|
||||
|
||||
const { status } = await request(server)
|
||||
const { status } = await request(app)
|
||||
.delete(`/activity/${reaction.id}`)
|
||||
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
|
||||
import { AlbumController } from '@app/immich';
|
||||
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
|
||||
import { SharedLinkType } from '@app/infra/entities';
|
||||
import { errorStub, userDto, uuidStub } from '@test/fixtures';
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkType,
|
||||
deleteUser,
|
||||
} from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const user1SharedUser = 'user1SharedUser';
|
||||
const user1SharedLink = 'user1SharedLink';
|
||||
@@ -14,193 +18,327 @@ const user2SharedUser = 'user2SharedUser';
|
||||
const user2SharedLink = 'user2SharedLink';
|
||||
const user2NotShared = 'user2NotShared';
|
||||
|
||||
describe(`${AlbumController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
describe('/album', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user1Asset: AssetFileUploadResponseDto;
|
||||
let user1Asset1: AssetFileUploadResponseDto;
|
||||
let user1Asset2: AssetFileUploadResponseDto;
|
||||
let user1Albums: AlbumResponseDto[];
|
||||
let user2: LoginResponseDto;
|
||||
let user2Albums: AlbumResponseDto[];
|
||||
let user3: LoginResponseDto; // deleted
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
admin = await apiUtils.adminSetup();
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
|
||||
await Promise.all([
|
||||
api.userApi.create(server, admin.accessToken, userDto.user1),
|
||||
api.userApi.create(server, admin.accessToken, userDto.user2),
|
||||
[user1, user2, user3] = await Promise.all([
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
|
||||
]);
|
||||
|
||||
[user1, user2] = await Promise.all([
|
||||
api.authApi.login(server, userDto.user1),
|
||||
api.authApi.login(server, userDto.user2),
|
||||
[user1Asset1, user1Asset2] = await Promise.all([
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
|
||||
|
||||
const albums = await Promise.all([
|
||||
// user 1
|
||||
api.albumApi.create(server, user1.accessToken, {
|
||||
apiUtils.createAlbum(user1.accessToken, {
|
||||
albumName: user1SharedUser,
|
||||
sharedWithUserIds: [user2.userId],
|
||||
assetIds: [user1Asset.id],
|
||||
assetIds: [user1Asset1.id],
|
||||
}),
|
||||
apiUtils.createAlbum(user1.accessToken, {
|
||||
albumName: user1SharedLink,
|
||||
assetIds: [user1Asset1.id],
|
||||
}),
|
||||
apiUtils.createAlbum(user1.accessToken, {
|
||||
albumName: user1NotShared,
|
||||
assetIds: [user1Asset1.id, user1Asset2.id],
|
||||
}),
|
||||
api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
|
||||
api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
|
||||
|
||||
// user 2
|
||||
api.albumApi.create(server, user2.accessToken, {
|
||||
apiUtils.createAlbum(user2.accessToken, {
|
||||
albumName: user2SharedUser,
|
||||
sharedWithUserIds: [user1.userId],
|
||||
assetIds: [user1Asset.id],
|
||||
assetIds: [user1Asset1.id],
|
||||
}),
|
||||
apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
|
||||
apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
|
||||
|
||||
// user 3
|
||||
apiUtils.createAlbum(user3.accessToken, {
|
||||
albumName: 'Deleted',
|
||||
sharedWithUserIds: [user1.userId],
|
||||
}),
|
||||
api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }),
|
||||
api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
|
||||
]);
|
||||
|
||||
user1Albums = albums.slice(0, 3);
|
||||
user2Albums = albums.slice(3);
|
||||
user2Albums = albums.slice(3, 6);
|
||||
|
||||
await Promise.all([
|
||||
// add shared link to user1SharedLink album
|
||||
api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: user1Albums[1].id,
|
||||
}),
|
||||
|
||||
// add shared link to user2SharedLink album
|
||||
api.sharedLinkApi.create(server, user2.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
apiUtils.createSharedLink(user2.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: user2Albums[1].id,
|
||||
}),
|
||||
]);
|
||||
|
||||
await deleteUser(
|
||||
{ id: user3.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
});
|
||||
|
||||
describe('GET /album', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get('/album');
|
||||
const { status, body } = await request(app).get('/album');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should reject an invalid shared param', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/album?shared=invalid')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value']));
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['shared must be a boolean value']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId param', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/album?assetId=invalid')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID']));
|
||||
expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
|
||||
});
|
||||
|
||||
it('should not return shared albums with a deleted owner', async () => {
|
||||
await api.userApi.delete(server, admin.accessToken, user1.userId);
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/album?shared=true')
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedLink,
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedUser,
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user2.userId,
|
||||
albumName: user2SharedUser,
|
||||
shared: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the album collection including owned and shared', async () => {
|
||||
const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
const { status, body } = await request(app)
|
||||
.get('/album')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
|
||||
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
|
||||
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedUser,
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedLink,
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1NotShared,
|
||||
shared: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by shared', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/album?shared=true')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
|
||||
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
|
||||
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedUser,
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1SharedLink,
|
||||
shared: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ownerId: user2.userId,
|
||||
albumName: user2SharedUser,
|
||||
shared: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by NOT shared', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/album?shared=false')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
|
||||
expect.objectContaining({
|
||||
ownerId: user1.userId,
|
||||
albumName: user1NotShared,
|
||||
shared: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by assetId', async () => {
|
||||
const asset = await api.assetApi.upload(server, user1.accessToken, 'example2');
|
||||
await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] });
|
||||
const { status, body } = await request(server)
|
||||
.get(`/album?assetId=${asset.id}`)
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album?assetId=${user1Asset2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by assetId and ignores shared=true', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get(`/album?shared=true&assetId=${user1Asset.id}`)
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album?shared=true&assetId=${user1Asset1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should return the album collection filtered by assetId and ignores shared=false', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get(`/album?shared=false&assetId=${user1Asset.id}`)
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album?shared=false&assetId=${user1Asset1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /album/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
`/album/${user1Albums[0].id}`,
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return album info for own album', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining(user1Albums[0].assets[0])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return album info for shared album', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user2Albums[0],
|
||||
assets: [expect.objectContaining(user2Albums[0].assets[0])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return album info with assets when withoutAssets is undefined', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [expect.objectContaining(user1Albums[0].assets[0])],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return album info without assets when withoutAssets is true', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [],
|
||||
assetCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /album/count', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/album/count');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return total count of albums the user has access to', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/album/count')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /album', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post('/album').send({ albumName: 'New album' });
|
||||
const { status, body } = await request(app)
|
||||
.post('/album')
|
||||
.send({ albumName: 'New album' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should create an album', async () => {
|
||||
const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
|
||||
const { status, body } = await request(app)
|
||||
.post('/album')
|
||||
.send({ albumName: 'New album' })
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual({
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
@@ -220,113 +358,56 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /album/count', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get('/album/count');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should return total count of albums the user has access to', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get('/album/count')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /album/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should return album info for own album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
|
||||
});
|
||||
|
||||
it('should return album info for shared album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] });
|
||||
});
|
||||
|
||||
it('should return album info with assets when withoutAssets is undefined', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get(`/album/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
|
||||
});
|
||||
|
||||
it('should return album info without assets when withoutAssets is true', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...user1Albums[0],
|
||||
assets: [],
|
||||
assetCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /album/:id/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`);
|
||||
const { status, body } = await request(app).put(
|
||||
`/album/${user1Albums[0].id}/assets`,
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should be able to add own asset to own album', async () => {
|
||||
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
|
||||
const { status, body } = await request(server)
|
||||
const asset = await apiUtils.createAsset(user1.accessToken);
|
||||
const { status, body } = await request(app)
|
||||
.put(`/album/${user1Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: asset.id, success: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be able to add own asset to shared album', async () => {
|
||||
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
|
||||
const { status, body } = await request(server)
|
||||
const asset = await apiUtils.createAsset(user1.accessToken);
|
||||
const { status, body } = await request(app)
|
||||
.put(`/album/${user2Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: asset.id, success: true }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /album/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.patch(`/album/${uuidStub.notFound}`)
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/album/${uuidDto.notFound}`)
|
||||
.send({ albumName: 'New album name' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should update an album', async () => {
|
||||
const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
|
||||
const { status, body } = await request(server)
|
||||
const album = await apiUtils.createAlbum(user1.accessToken, {
|
||||
albumName: 'New album',
|
||||
});
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/album/${album.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
@@ -345,52 +426,68 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
||||
|
||||
describe('DELETE /album/:id/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/album/${user1Albums[0].id}/assets`)
|
||||
.send({ ids: [user1Asset.id] });
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from own album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.delete(`/album/${user1Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [user1Asset.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from shared album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.delete(`/album/${user2Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [user1Asset.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should not be able to remove foreign asset from own album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/album/${user2Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ ids: [user1Asset.id] });
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({
|
||||
id: user1Asset1.id,
|
||||
success: false,
|
||||
error: 'no_permission',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not be able to remove foreign asset from foreign album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/album/${user1Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ ids: [user1Asset.id] });
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({
|
||||
id: user1Asset1.id,
|
||||
success: false,
|
||||
error: 'no_permission',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from own album', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/album/${user1Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: user1Asset1.id, success: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from shared album', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/album/${user2Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: user1Asset1.id, success: true }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,51 +495,57 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
||||
let album: AlbumResponseDto;
|
||||
|
||||
beforeEach(async () => {
|
||||
album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' });
|
||||
album = await apiUtils.createAlbum(user1.accessToken, {
|
||||
albumName: 'testAlbum',
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.put(`/album/${user1Albums[0].id}/users`)
|
||||
.send({ sharedUserIds: [] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should be able to add user to own album', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.put(`/album/${album.id}/users`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ sharedUserIds: [user2.userId] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] }));
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
sharedUsers: [expect.objectContaining({ id: user2.userId })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not be able to share album with owner', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.put(`/album/${album.id}/users`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ sharedUserIds: [user1.userId] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner'));
|
||||
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
|
||||
});
|
||||
|
||||
it('should not be able to add existing user to shared album', async () => {
|
||||
await request(server)
|
||||
await request(app)
|
||||
.put(`/album/${album.id}/users`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ sharedUserIds: [user2.userId] });
|
||||
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.put(`/album/${album.id}/users`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ sharedUserIds: [user2.userId] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('User already added'));
|
||||
expect(body).toEqual(errorDto.badRequest('User already added'));
|
||||
});
|
||||
});
|
||||
});
|
||||
808
e2e/src/api/specs/asset.e2e-spec.ts
Normal file
808
e2e/src/api/specs/asset.e2e-spec.ts
Normal file
@@ -0,0 +1,808 @@
|
||||
import {
|
||||
AssetFileUploadResponseDto,
|
||||
AssetResponseDto,
|
||||
AssetTypeEnum,
|
||||
LoginResponseDto,
|
||||
SharedLinkType,
|
||||
} from '@immich/sdk';
|
||||
import { exiftool } from 'exiftool-vendored';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import {
|
||||
apiUtils,
|
||||
app,
|
||||
dbUtils,
|
||||
tempDir,
|
||||
testAssetDir,
|
||||
wsUtils,
|
||||
} from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||
|
||||
const sha1 = (bytes: Buffer) =>
|
||||
createHash('sha1').update(bytes).digest('base64');
|
||||
|
||||
const readTags = async (bytes: Buffer, filename: string) => {
|
||||
const filepath = join(tempDir, filename);
|
||||
await writeFile(filepath, bytes);
|
||||
return exiftool.read(filepath);
|
||||
};
|
||||
|
||||
const today = DateTime.fromObject({
|
||||
year: 2023,
|
||||
month: 11,
|
||||
day: 3,
|
||||
}) as DateTime<true>;
|
||||
const yesterday = today.minus({ days: 1 });
|
||||
|
||||
describe('/asset', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
let userStats: LoginResponseDto;
|
||||
let user1Assets: AssetFileUploadResponseDto[];
|
||||
let user2Assets: AssetFileUploadResponseDto[];
|
||||
let assetLocation: AssetFileUploadResponseDto;
|
||||
let ws: Socket;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup({ onboarding: false });
|
||||
|
||||
[ws, user1, user2, userStats] = await Promise.all([
|
||||
wsUtils.connect(admin.accessToken),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
|
||||
]);
|
||||
|
||||
// asset location
|
||||
assetLocation = await apiUtils.createAsset(
|
||||
admin.accessToken,
|
||||
{},
|
||||
{
|
||||
filename: 'thompson-springs.jpg',
|
||||
bytes: await readFile(locationAssetFilepath),
|
||||
},
|
||||
);
|
||||
|
||||
await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id });
|
||||
|
||||
user1Assets = await Promise.all([
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(
|
||||
user1.accessToken,
|
||||
{
|
||||
isFavorite: true,
|
||||
isExternal: true,
|
||||
isReadOnly: true,
|
||||
fileCreatedAt: yesterday.toISO(),
|
||||
fileModifiedAt: yesterday.toISO(),
|
||||
},
|
||||
{ filename: 'example.mp4' },
|
||||
),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
|
||||
|
||||
await Promise.all([
|
||||
// stats
|
||||
apiUtils.createAsset(userStats.accessToken),
|
||||
apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
|
||||
apiUtils.createAsset(userStats.accessToken, { isArchived: true }),
|
||||
apiUtils.createAsset(
|
||||
userStats.accessToken,
|
||||
{
|
||||
isArchived: true,
|
||||
isFavorite: true,
|
||||
},
|
||||
{ filename: 'example.mp4' },
|
||||
),
|
||||
]);
|
||||
|
||||
const person1 = await apiUtils.createPerson(user1.accessToken, {
|
||||
name: 'Test Person',
|
||||
});
|
||||
await dbUtils.createFace({
|
||||
assetId: user1Assets[0].id,
|
||||
personId: person1.id,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
wsUtils.disconnect(ws);
|
||||
});
|
||||
|
||||
describe('GET /asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
`/asset/${uuidDto.notFound}`,
|
||||
);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user2Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
it('should get the asset info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id });
|
||||
});
|
||||
|
||||
it('should work with a shared link', async () => {
|
||||
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [user1Assets[0].id],
|
||||
});
|
||||
|
||||
const { status, body } = await request(app).get(
|
||||
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id });
|
||||
});
|
||||
|
||||
it('should not send people data for shared links for un-authenticated users', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
isFavorite: false,
|
||||
people: [
|
||||
{
|
||||
birthDate: null,
|
||||
id: expect.any(String),
|
||||
isHidden: false,
|
||||
name: 'Test Person',
|
||||
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [user1Assets[0].id],
|
||||
});
|
||||
|
||||
const data = await request(app).get(
|
||||
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
|
||||
);
|
||||
expect(data.status).toBe(200);
|
||||
expect(data.body).toMatchObject({ people: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/asset/statistics');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return stats of all assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`);
|
||||
|
||||
expect(body).toEqual({ images: 3, videos: 1, total: 4 });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return stats of all favored assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isFavorite: true });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 1, videos: 1, total: 2 });
|
||||
});
|
||||
|
||||
it('should return stats of all archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isArchived: true });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 1, videos: 1, total: 2 });
|
||||
});
|
||||
|
||||
it('should return stats of all favored and archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isFavorite: true, isArchived: true });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 0, videos: 1, total: 1 });
|
||||
});
|
||||
|
||||
it('should return stats of all assets neither favored nor archived', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
||||
.query({ isFavorite: false, isArchived: false });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ images: 1, videos: 0, total: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/random', () => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all([
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/asset/random');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it.each(Array(10))('should return 1 random assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const assets: AssetResponseDto[] = body;
|
||||
expect(assets.length).toBe(1);
|
||||
expect(assets[0].ownerId).toBe(user1.userId);
|
||||
|
||||
// assets owned by user1
|
||||
expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id);
|
||||
// assets owned by user2
|
||||
expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id);
|
||||
});
|
||||
|
||||
it.each(Array(10))('should return 2 random assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random?count=2')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const assets: AssetResponseDto[] = body;
|
||||
expect(assets.length).toBe(2);
|
||||
|
||||
for (const asset of assets) {
|
||||
expect(asset.ownerId).toBe(user1.userId);
|
||||
// assets owned by user1
|
||||
expect([user1Assets.map(({ id }) => id)]).toContain(asset.id);
|
||||
// assets owned by user2
|
||||
expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Array(10))(
|
||||
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
|
||||
async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/[]asset/random')
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({ id: user2Assets[0].id }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('should return error', async () => {
|
||||
const { status } = await request(app)
|
||||
.get('/asset/random?count=ABC')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(
|
||||
`/asset/:${uuidDto.notFound}`,
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user2Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
it('should favorite an asset', async () => {
|
||||
const before = await apiUtils.getAssetInfo(
|
||||
user1.accessToken,
|
||||
user1Assets[0].id,
|
||||
);
|
||||
expect(before.isFavorite).toBe(false);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should archive an asset', async () => {
|
||||
const before = await apiUtils.getAssetInfo(
|
||||
user1.accessToken,
|
||||
user1Assets[0].id,
|
||||
);
|
||||
expect(before.isArchived).toBe(false);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isArchived: true });
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should update date time original', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
dateTimeOriginal: '2023-11-20T01:11:00.000Z',
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should reject invalid gps coordinates', async () => {
|
||||
for (const test of [
|
||||
{ latitude: 12 },
|
||||
{ longitude: 12 },
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
{ latitude: null, longitude: 12 },
|
||||
{ latitude: 12, longitude: null },
|
||||
{ latitude: 91, longitude: 12 },
|
||||
{ latitude: -91, longitude: 12 },
|
||||
{ latitude: 12, longitude: -181 },
|
||||
{ latitude: 12, longitude: 181 },
|
||||
]) {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.send(test)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
it('should update gps data', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ latitude: 12, longitude: 12 });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should set the description', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ description: 'Test asset description' });
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
description: 'Test asset description',
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should return tagged people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
isFavorite: true,
|
||||
people: [
|
||||
{
|
||||
birthDate: null,
|
||||
id: expect.any(String),
|
||||
isHidden: false,
|
||||
name: 'Test Person',
|
||||
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /asset', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.send({ ids: [uuidDto.notFound] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.send({ ids: [uuidDto.invalid] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['each value in ids must be a UUID']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when the id is not found', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.send({ ids: [uuidDto.notFound] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest('Not found or no asset.delete access'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should move an asset to the trash', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
|
||||
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(before.isTrashed).toBe(false);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete('/asset')
|
||||
.send({ ids: [assetId] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(after.isTrashed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /asset/upload', () => {
|
||||
const tests = [
|
||||
{
|
||||
input: 'formats/jpg/el_torcal_rocks.jpg',
|
||||
expected: {
|
||||
type: AssetTypeEnum.Image,
|
||||
originalFileName: 'el_torcal_rocks',
|
||||
resized: true,
|
||||
exifInfo: {
|
||||
dateTimeOriginal: '2012-08-05T11:39:59.000Z',
|
||||
exifImageWidth: 512,
|
||||
exifImageHeight: 341,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
focalLength: 75,
|
||||
iso: 200,
|
||||
fNumber: 11,
|
||||
exposureTime: '1/160',
|
||||
fileSizeInByte: 53_493,
|
||||
make: 'SONY',
|
||||
model: 'DSLR-A550',
|
||||
orientation: null,
|
||||
description: 'SONY DSC',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 'formats/heic/IMG_2682.heic',
|
||||
expected: {
|
||||
type: AssetTypeEnum.Image,
|
||||
originalFileName: 'IMG_2682',
|
||||
resized: true,
|
||||
fileCreatedAt: '2019-03-21T16:04:22.348Z',
|
||||
exifInfo: {
|
||||
dateTimeOriginal: '2019-03-21T16:04:22.348Z',
|
||||
exifImageWidth: 4032,
|
||||
exifImageHeight: 3024,
|
||||
latitude: 41.2203,
|
||||
longitude: -96.071_625,
|
||||
make: 'Apple',
|
||||
model: 'iPhone 7',
|
||||
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
|
||||
fileSizeInByte: 880_703,
|
||||
exposureTime: '1/887',
|
||||
iso: 20,
|
||||
focalLength: 3.99,
|
||||
fNumber: 1.8,
|
||||
timeZone: 'America/Chicago',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 'formats/png/density_plot.png',
|
||||
expected: {
|
||||
type: AssetTypeEnum.Image,
|
||||
originalFileName: 'density_plot',
|
||||
resized: true,
|
||||
exifInfo: {
|
||||
exifImageWidth: 800,
|
||||
exifImageHeight: 800,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
fileSizeInByte: 25_408,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 'formats/raw/Nikon/D80/glarus.nef',
|
||||
expected: {
|
||||
type: AssetTypeEnum.Image,
|
||||
originalFileName: 'glarus',
|
||||
resized: true,
|
||||
fileCreatedAt: '2010-07-20T17:27:12.000Z',
|
||||
exifInfo: {
|
||||
make: 'NIKON CORPORATION',
|
||||
model: 'NIKON D80',
|
||||
exposureTime: '1/200',
|
||||
fNumber: 10,
|
||||
focalLength: 18,
|
||||
iso: 100,
|
||||
fileSizeInByte: 9_057_784,
|
||||
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
orientation: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 'formats/raw/Nikon/D700/philadelphia.nef',
|
||||
expected: {
|
||||
type: AssetTypeEnum.Image,
|
||||
originalFileName: 'philadelphia',
|
||||
resized: true,
|
||||
fileCreatedAt: '2016-09-22T22:10:29.060Z',
|
||||
exifInfo: {
|
||||
make: 'NIKON CORPORATION',
|
||||
model: 'NIKON D700',
|
||||
exposureTime: '1/400',
|
||||
fNumber: 11,
|
||||
focalLength: 85,
|
||||
iso: 200,
|
||||
fileSizeInByte: 15_856_335,
|
||||
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
orientation: '1',
|
||||
timeZone: 'UTC-5',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const { input, expected } of tests) {
|
||||
it(`should generate a thumbnail for ${input}`, async () => {
|
||||
const filepath = join(testAssetDir, input);
|
||||
const { id, duplicate } = await apiUtils.createAsset(
|
||||
admin.accessToken,
|
||||
{},
|
||||
{ bytes: await readFile(filepath), filename: basename(filepath) },
|
||||
);
|
||||
|
||||
expect(duplicate).toBe(false);
|
||||
|
||||
await wsUtils.waitForEvent({ event: 'upload', assetId: id });
|
||||
|
||||
const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
|
||||
|
||||
expect(asset.exifInfo).toBeDefined();
|
||||
expect(asset.exifInfo).toMatchObject(expected.exifInfo);
|
||||
expect(asset).toMatchObject(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('should handle a duplicate', async () => {
|
||||
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
|
||||
const { duplicate } = await apiUtils.createAsset(
|
||||
admin.accessToken,
|
||||
{},
|
||||
{
|
||||
bytes: await readFile(join(testAssetDir, filepath)),
|
||||
filename: basename(filepath),
|
||||
},
|
||||
);
|
||||
|
||||
expect(duplicate).toBe(true);
|
||||
});
|
||||
|
||||
// These hashes were created by copying the image files to a Samsung phone,
|
||||
// exporting the video from Samsung's stock Gallery app, and hashing them locally.
|
||||
// This ensures that immich+exiftool are extracting the videos the same way Samsung does.
|
||||
// DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
|
||||
// into the test here.
|
||||
const motionTests = [
|
||||
{
|
||||
filepath: 'formats/motionphoto/Samsung One UI 5.jpg',
|
||||
checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=',
|
||||
},
|
||||
{
|
||||
filepath: 'formats/motionphoto/Samsung One UI 6.jpg',
|
||||
checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=',
|
||||
},
|
||||
{
|
||||
filepath: 'formats/motionphoto/Samsung One UI 6.heic',
|
||||
checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { filepath, checksum } of motionTests) {
|
||||
it(`should extract motionphoto video from ${filepath}`, async () => {
|
||||
const response = await apiUtils.createAsset(
|
||||
admin.accessToken,
|
||||
{},
|
||||
{
|
||||
bytes: await readFile(join(testAssetDir, filepath)),
|
||||
filename: basename(filepath),
|
||||
},
|
||||
);
|
||||
|
||||
await wsUtils.waitForEvent({ event: 'upload', assetId: response.id });
|
||||
|
||||
expect(response.duplicate).toBe(false);
|
||||
|
||||
const asset = await apiUtils.getAssetInfo(
|
||||
admin.accessToken,
|
||||
response.id,
|
||||
);
|
||||
expect(asset.livePhotoVideoId).toBeDefined();
|
||||
|
||||
const video = await apiUtils.getAssetInfo(
|
||||
admin.accessToken,
|
||||
asset.livePhotoVideoId as string,
|
||||
);
|
||||
expect(video.checksum).toStrictEqual(checksum);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /asset/thumbnail/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
`/asset/thumbnail/${assetLocation.id}`,
|
||||
);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should not include gps data for webp thumbnails', async () => {
|
||||
const { status, body, type } = await request(app)
|
||||
.get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
await wsUtils.waitForEvent({
|
||||
event: 'upload',
|
||||
assetId: assetLocation.id,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toBeDefined();
|
||||
expect(type).toBe('image/webp');
|
||||
|
||||
const exifData = await readTags(body, 'thumbnail.webp');
|
||||
expect(exifData).not.toHaveProperty('GPSLongitude');
|
||||
expect(exifData).not.toHaveProperty('GPSLatitude');
|
||||
});
|
||||
|
||||
it('should not include gps data for jpeg thumbnails', async () => {
|
||||
const { status, body, type } = await request(app)
|
||||
.get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toBeDefined();
|
||||
expect(type).toBe('image/jpeg');
|
||||
|
||||
const exifData = await readTags(body, 'thumbnail.jpg');
|
||||
expect(exifData).not.toHaveProperty('GPSLongitude');
|
||||
expect(exifData).not.toHaveProperty('GPSLatitude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/file/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
`/asset/thumbnail/${assetLocation.id}`,
|
||||
);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should download the original', async () => {
|
||||
const { status, body, type } = await request(app)
|
||||
.get(`/asset/file/${assetLocation.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toBeDefined();
|
||||
expect(type).toBe('image/jpeg');
|
||||
|
||||
const asset = await apiUtils.getAssetInfo(
|
||||
admin.accessToken,
|
||||
assetLocation.id,
|
||||
);
|
||||
|
||||
const original = await readFile(locationAssetFilepath);
|
||||
const originalChecksum = sha1(original);
|
||||
const downloadChecksum = sha1(body);
|
||||
|
||||
expect(originalChecksum).toBe(downloadChecksum);
|
||||
expect(downloadChecksum).toBe(asset.checksum);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
e2e/src/api/specs/audit.e2e-spec.ts
Normal file
51
e2e/src/api/specs/audit.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
@@ -6,7 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/download', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetResponseDto;
|
||||
let asset1: AssetFileUploadResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
@@ -35,7 +35,7 @@ describe('/download', () => {
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
archives: [expect.objectContaining({ assetIds: [asset1.id] })],
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('/download', () => {
|
||||
describe('POST /download/asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(
|
||||
`/download/asset/${asset1.id}`
|
||||
`/download/asset/${asset1.id}`,
|
||||
);
|
||||
|
||||
expect(status).toBe(401);
|
||||
|
||||
178
e2e/src/api/specs/person.e2e-spec.ts
Normal file
178
e2e/src/api/specs/person.e2e-spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,22 @@
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetResponseDto,
|
||||
IAssetRepository,
|
||||
AssetFileUploadResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
} from '@app/domain';
|
||||
import { SharedLinkController } from '@app/immich';
|
||||
import { SharedLinkType } from '@app/infra/entities';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { errorStub, userDto, uuidStub } from '@test/fixtures';
|
||||
import { DateTime } from 'luxon';
|
||||
SharedLinkType,
|
||||
createAlbum,
|
||||
deleteUser,
|
||||
} from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
describe('/shared-link', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetResponseDto;
|
||||
let asset2: AssetResponseDto;
|
||||
let asset1: AssetFileUploadResponseDto;
|
||||
let asset2: AssetFileUploadResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
@@ -30,97 +28,96 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
let linkWithAssets: SharedLinkResponseDto;
|
||||
let linkWithMetadata: SharedLinkResponseDto;
|
||||
let linkWithoutMetadata: SharedLinkResponseDto;
|
||||
let app: INestApplication<any>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await testApp.create();
|
||||
server = app.getHttpServer();
|
||||
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
|
||||
await Promise.all([
|
||||
api.userApi.create(server, admin.accessToken, userDto.user1),
|
||||
api.userApi.create(server, admin.accessToken, userDto.user2),
|
||||
]);
|
||||
admin = await apiUtils.adminSetup();
|
||||
|
||||
[user1, user2] = await Promise.all([
|
||||
api.authApi.login(server, userDto.user1),
|
||||
api.authApi.login(server, userDto.user2),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
]);
|
||||
|
||||
[asset1, asset2] = await Promise.all([
|
||||
api.assetApi.create(server, user1.accessToken),
|
||||
api.assetApi.create(server, user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
apiUtils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
await assetRepository.upsertExif({
|
||||
assetId: asset1.id,
|
||||
longitude: -108.400968333333,
|
||||
latitude: 39.115,
|
||||
orientation: '1',
|
||||
dateTimeOriginal: DateTime.fromISO('2022-01-10T19:15:44.310Z').toJSDate(),
|
||||
timeZone: 'UTC-4',
|
||||
state: 'Mesa County, Colorado',
|
||||
country: 'United States of America',
|
||||
});
|
||||
|
||||
[album, deletedAlbum, metadataAlbum] = await Promise.all([
|
||||
api.albumApi.create(server, user1.accessToken, { albumName: 'album' }),
|
||||
api.albumApi.create(server, user2.accessToken, { albumName: 'deleted album' }),
|
||||
api.albumApi.create(server, user1.accessToken, { albumName: 'metadata album', assetIds: [asset1.id] }),
|
||||
createAlbum(
|
||||
{ createAlbumDto: { albumName: 'album' } },
|
||||
{ headers: asBearerAuth(user1.accessToken) },
|
||||
),
|
||||
createAlbum(
|
||||
{ createAlbumDto: { albumName: 'deleted album' } },
|
||||
{ headers: asBearerAuth(user2.accessToken) },
|
||||
),
|
||||
createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
albumName: 'metadata album',
|
||||
assetIds: [asset1.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(user1.accessToken) },
|
||||
),
|
||||
]);
|
||||
|
||||
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
||||
await Promise.all([
|
||||
api.sharedLinkApi.create(server, user2.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: deletedAlbum.id,
|
||||
}),
|
||||
api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: album.id,
|
||||
}),
|
||||
api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
assetIds: [asset1.id],
|
||||
}),
|
||||
api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: album.id,
|
||||
password: 'foo',
|
||||
}),
|
||||
api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: metadataAlbum.id,
|
||||
showMetadata: true,
|
||||
}),
|
||||
api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: metadataAlbum.id,
|
||||
showMetadata: false,
|
||||
}),
|
||||
]);
|
||||
[
|
||||
linkWithDeletedAlbum,
|
||||
linkWithAlbum,
|
||||
linkWithAssets,
|
||||
linkWithPassword,
|
||||
linkWithMetadata,
|
||||
linkWithoutMetadata,
|
||||
] = await Promise.all([
|
||||
apiUtils.createSharedLink(user2.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: deletedAlbum.id,
|
||||
}),
|
||||
apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
}),
|
||||
apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset1.id],
|
||||
}),
|
||||
apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
password: 'foo',
|
||||
}),
|
||||
apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: metadataAlbum.id,
|
||||
showMetadata: true,
|
||||
}),
|
||||
apiUtils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: metadataAlbum.id,
|
||||
showMetadata: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
await api.userApi.delete(server, admin.accessToken, user2.userId);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await deleteUser(
|
||||
{ id: user2.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
});
|
||||
|
||||
describe('GET /shared-link', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get('/shared-link');
|
||||
const { status, body } = await request(app).get('/shared-link');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get all shared links created by user', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
@@ -138,7 +135,7 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should not get shared links created by other users', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
@@ -149,7 +146,7 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
|
||||
describe('GET /shared-link/me', () => {
|
||||
it('should not require admin authentication', async () => {
|
||||
const { status } = await request(server)
|
||||
const { status } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
@@ -157,52 +154,66 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should get data for correct shared link', async () => {
|
||||
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithAlbum.key });
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithAlbum.key });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
album,
|
||||
userId: user1.userId,
|
||||
type: SharedLinkType.ALBUM,
|
||||
type: SharedLinkType.Album,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unauthorized for incorrect shared link', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithAlbum.key + 'foo' });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidShareKey);
|
||||
expect(body).toEqual(errorDto.invalidShareKey);
|
||||
});
|
||||
|
||||
it('should return unauthorized if target has been soft deleted', async () => {
|
||||
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key });
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithDeletedAlbum.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidShareKey);
|
||||
expect(body).toEqual(errorDto.invalidShareKey);
|
||||
});
|
||||
|
||||
it('should return unauthorized for password protected link', async () => {
|
||||
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithPassword.key });
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithPassword.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidSharePassword);
|
||||
expect(body).toEqual(errorDto.invalidSharePassword);
|
||||
});
|
||||
|
||||
it('should get data for correct password protected link', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithPassword.key, password: 'foo' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
album,
|
||||
userId: user1.userId,
|
||||
type: SharedLinkType.Album,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return metadata for album shared link', async () => {
|
||||
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithMetadata.key });
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithMetadata.key });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets).toHaveLength(1);
|
||||
@@ -211,22 +222,16 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
originalFileName: 'example',
|
||||
localDateTime: expect.any(String),
|
||||
fileCreatedAt: expect.any(String),
|
||||
exifInfo: expect.objectContaining({
|
||||
longitude: -108.400968333333,
|
||||
latitude: 39.115,
|
||||
orientation: '1',
|
||||
dateTimeOriginal: expect.any(String),
|
||||
timeZone: 'UTC-4',
|
||||
state: 'Mesa County, Colorado',
|
||||
country: 'United States of America',
|
||||
}),
|
||||
exifInfo: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(body.album).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not return metadata for album shared link without metadata', async () => {
|
||||
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithoutMetadata.key });
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: linkWithoutMetadata.key });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets).toHaveLength(1);
|
||||
@@ -242,127 +247,150 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
|
||||
describe('GET /shared-link/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get(`/shared-link/${linkWithAlbum.id}`);
|
||||
const { status, body } = await request(app).get(
|
||||
`/shared-link/${linkWithAlbum.id}`,
|
||||
);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get shared link by id', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get(`/shared-link/${linkWithAlbum.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
album,
|
||||
userId: user1.userId,
|
||||
type: SharedLinkType.Album,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not get shared link by id if user has not created the link or it does not exist', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.get(`/shared-link/${linkWithAlbum.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Shared link not found' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /shared-link', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-link')
|
||||
.send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound });
|
||||
.send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a type and the correspondent asset/album id', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-link')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require an asset/album id', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-link')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ type: SharedLinkType.ALBUM });
|
||||
.send({ type: SharedLinkType.Album });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Invalid albumId' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-link')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound });
|
||||
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Invalid assetIds' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a shared link', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.post('/shared-link')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ type: SharedLinkType.ALBUM, albumId: album.id });
|
||||
.send({ type: SharedLinkType.Album, albumId: album.id });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId }));
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
type: SharedLinkType.Album,
|
||||
userId: user1.userId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /shared-link/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/shared-link/${linkWithAlbum.id}`)
|
||||
.send({ description: 'foo' });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should fail if invalid link', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.patch(`/shared-link/${uuidStub.notFound}`)
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/shared-link/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ description: 'foo' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should update shared link', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/shared-link/${linkWithAlbum.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ description: 'foo' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId, description: 'foo' }),
|
||||
expect.objectContaining({
|
||||
type: SharedLinkType.Album,
|
||||
userId: user1.userId,
|
||||
description: 'foo',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /shared-link/:id/assets', () => {
|
||||
it('should not add assets to shared link (album)', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.put(`/shared-link/${linkWithAlbum.id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [asset2.id] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
|
||||
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
||||
});
|
||||
|
||||
it('should add an assets to a shared link (individual)', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.put(`/shared-link/${linkWithAssets.id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [asset2.id] });
|
||||
@@ -374,17 +402,17 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
|
||||
describe('DELETE /shared-link/:id/assets', () => {
|
||||
it('should not remove assets from a shared link (album)', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-link/${linkWithAlbum.id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [asset2.id] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
|
||||
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
||||
});
|
||||
|
||||
it('should remove assets from a shared link (individual)', async () => {
|
||||
const { status, body } = await request(server)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-link/${linkWithAssets.id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [asset2.id] });
|
||||
@@ -396,23 +424,25 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||
|
||||
describe('DELETE /shared-link/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).delete(`/shared-link/${linkWithAlbum.id}`);
|
||||
const { status, body } = await request(app).delete(
|
||||
`/shared-link/${linkWithAlbum.id}`,
|
||||
);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should fail if invalid link', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.delete(`/shared-link/${uuidStub.notFound}`)
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/shared-link/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should delete a shared link', async () => {
|
||||
const { status } = await request(server)
|
||||
const { status } = await request(app)
|
||||
.delete(`/shared-link/${linkWithAlbum.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
107
e2e/src/api/specs/trash.e2e-spec.ts
Normal file
107
e2e/src/api/specs/trash.e2e-spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { LoginResponseDto, getAllAssets } from '@immich/sdk';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils, wsUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/trash', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let ws: Socket;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup({ onboarding: false });
|
||||
ws = await wsUtils.connect(admin.accessToken);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wsUtils.disconnect(ws);
|
||||
});
|
||||
|
||||
describe('POST /trash/empty', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/trash/empty');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should empty the trash', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
|
||||
|
||||
const before = await getAllAssets(
|
||||
{},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
|
||||
expect(before.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/trash/empty')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
await wsUtils.waitForEvent({ event: 'delete', assetId });
|
||||
|
||||
const after = await getAllAssets(
|
||||
{},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
expect(after.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trash/restore', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/trash/restore');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should restore all trashed assets', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
|
||||
|
||||
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(before.isTrashed).toBe(true);
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/trash/restore')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(after.isTrashed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trash/restore/assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/trash/restore/assets');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should restore a trashed asset by id', async () => {
|
||||
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
|
||||
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
|
||||
|
||||
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(before.isTrashed).toBe(true);
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/trash/restore/assets')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ids: [assetId] });
|
||||
expect(status).toBe(204);
|
||||
|
||||
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
|
||||
expect(after.isTrashed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,31 @@
|
||||
import {
|
||||
LoginResponseDto,
|
||||
UserResponseDto,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
} from '@immich/sdk';
|
||||
import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk';
|
||||
import { createUserDto, userDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/server-info', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let deletedUser: LoginResponseDto;
|
||||
let userToDelete: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup({ onboarding: false });
|
||||
|
||||
[deletedUser, nonAdmin, userToDelete] = await Promise.all([
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
|
||||
]);
|
||||
|
||||
await deleteUser(
|
||||
{ id: deletedUser.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
});
|
||||
|
||||
describe('GET /user', () => {
|
||||
@@ -30,60 +35,54 @@ describe('/server-info', () => {
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should start with the admin', async () => {
|
||||
it('should get users', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/user')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
|
||||
expect(body).toHaveLength(4);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'admin@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user1@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user2@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user3@immich.cloud' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide deleted users', async () => {
|
||||
const user1 = await apiUtils.userSetup(
|
||||
admin.accessToken,
|
||||
createUserDto.user1
|
||||
);
|
||||
await deleteUser(
|
||||
{ id: user1.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get(`/user`)
|
||||
.query({ isAll: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'admin@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user2@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user3@immich.cloud' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should include deleted users', async () => {
|
||||
const user1 = await apiUtils.userSetup(
|
||||
admin.accessToken,
|
||||
createUserDto.user1
|
||||
);
|
||||
await deleteUser(
|
||||
{ id: user1.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get(`/user`)
|
||||
.query({ isAll: false })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0]).toMatchObject({
|
||||
id: user1.userId,
|
||||
email: 'user1@immich.cloud',
|
||||
deletedAt: expect.any(String),
|
||||
});
|
||||
expect(body[1]).toMatchObject({
|
||||
id: admin.userId,
|
||||
email: 'admin@immich.cloud',
|
||||
});
|
||||
expect(body).toHaveLength(4);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'admin@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user1@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user2@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user3@immich.cloud' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,13 +148,13 @@ describe('/server-info', () => {
|
||||
.post(`/user`)
|
||||
.send({
|
||||
isAdmin: true,
|
||||
email: 'user1@immich.cloud',
|
||||
password: 'Password123',
|
||||
email: 'user4@immich.cloud',
|
||||
password: 'password123',
|
||||
name: 'Immich',
|
||||
})
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(body).toMatchObject({
|
||||
email: 'user1@immich.cloud',
|
||||
email: 'user4@immich.cloud',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: true,
|
||||
});
|
||||
@@ -181,18 +180,9 @@ describe('/server-info', () => {
|
||||
});
|
||||
|
||||
describe('DELETE /user/:id', () => {
|
||||
let userToDelete: UserResponseDto;
|
||||
|
||||
beforeEach(async () => {
|
||||
userToDelete = await createUser(
|
||||
{ createUserDto: createUserDto.user1 },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(
|
||||
`/user/${userToDelete.id}`
|
||||
`/user/${userToDelete.userId}`
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
@@ -200,12 +190,12 @@ describe('/server-info', () => {
|
||||
|
||||
it('should delete user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/user/${userToDelete.id}`)
|
||||
.delete(`/user/${userToDelete.userId}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...userToDelete,
|
||||
expect(body).toMatchObject({
|
||||
id: userToDelete.userId,
|
||||
updatedAt: expect.any(String),
|
||||
deletedAt: expect.any(String),
|
||||
});
|
||||
@@ -231,14 +221,9 @@ describe('/server-info', () => {
|
||||
}
|
||||
|
||||
it('should not allow a non-admin to become an admin', async () => {
|
||||
const user = await apiUtils.userSetup(
|
||||
admin.accessToken,
|
||||
createUserDto.user1
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.send({ isAdmin: true, id: user.userId })
|
||||
.send({ isAdmin: true, id: nonAdmin.userId })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich server-info`, () => {
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
await cliUtils.login();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAllAlbums, getAllAssets } from '@immich/sdk';
|
||||
import { mkdir, readdir, rm, symlink } from 'fs/promises';
|
||||
import {
|
||||
apiUtils,
|
||||
asKeyAuth,
|
||||
@@ -8,18 +9,18 @@ import {
|
||||
testAssetDir,
|
||||
} from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdir, readdir, rm, symlink } from 'fs/promises';
|
||||
|
||||
describe(`immich upload`, () => {
|
||||
let key: string;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
key = await cliUtils.login();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
key = await cliUtils.login();
|
||||
await dbUtils.reset(['assets', 'albums']);
|
||||
});
|
||||
|
||||
describe('immich upload --recursive', () => {
|
||||
@@ -33,7 +34,7 @@ describe(`immich upload`, () => {
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -55,7 +56,7 @@ describe(`immich upload`, () => {
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -77,7 +78,7 @@ describe(`immich upload`, () => {
|
||||
expect(response1.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(response1.stderr).toBe('');
|
||||
expect(response1.exitCode).toBe(0);
|
||||
@@ -97,10 +98,10 @@ describe(`immich upload`, () => {
|
||||
expect(response2.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'All assets were already uploaded, nothing to do.'
|
||||
'All assets were already uploaded, nothing to do.',
|
||||
),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(response2.stderr).toBe('');
|
||||
expect(response2.exitCode).toBe(0);
|
||||
@@ -127,7 +128,7 @@ describe(`immich upload`, () => {
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
@@ -148,7 +149,7 @@ describe(`immich upload`, () => {
|
||||
for (const file of filesToLink) {
|
||||
await symlink(
|
||||
`${testAssetDir}/albums/nature/${file}`,
|
||||
`/tmp/albums/nature/${file}`
|
||||
`/tmp/albums/nature/${file}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,7 +167,7 @@ describe(`immich upload`, () => {
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Deleting assets that have been uploaded'),
|
||||
])
|
||||
]),
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
@@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process';
|
||||
|
||||
export default async () => {
|
||||
let _resolve: () => unknown;
|
||||
const promise = new Promise<void>((resolve) => (_resolve = resolve));
|
||||
const ready = new Promise<void>((resolve) => (_resolve = resolve));
|
||||
|
||||
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const input = data.toString();
|
||||
console.log(input);
|
||||
if (input.includes('Immich Server is listening')) {
|
||||
if (input.includes('Immich Microservices is listening')) {
|
||||
_resolve();
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => console.log(data.toString()));
|
||||
|
||||
await promise;
|
||||
await ready;
|
||||
|
||||
return async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
exec('docker compose down', () => resolve())
|
||||
exec('docker compose down', () => resolve()),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
236
e2e/src/utils.ts
236
e2e/src/utils.ts
@@ -1,24 +1,39 @@
|
||||
import {
|
||||
AssetFileUploadResponseDto,
|
||||
AssetResponseDto,
|
||||
CreateAlbumDto,
|
||||
CreateAssetDto,
|
||||
CreateUserDto,
|
||||
LoginResponseDto,
|
||||
PersonUpdateDto,
|
||||
SharedLinkCreateDto,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createPerson,
|
||||
createSharedLink,
|
||||
createUser,
|
||||
defaults,
|
||||
deleteAssets,
|
||||
getAssetInfo,
|
||||
login,
|
||||
setAdminOnboarding,
|
||||
signUpAdmin,
|
||||
updatePerson,
|
||||
} from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { spawn } from 'child_process';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:stream';
|
||||
import { promisify } from 'node:util';
|
||||
import pg from 'pg';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
import request from 'supertest';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
export const app = 'http://127.0.0.1:2283/api';
|
||||
|
||||
const directoryExists = (directory: string) =>
|
||||
@@ -28,14 +43,23 @@ const directoryExists = (directory: string) =>
|
||||
|
||||
// TODO move test assets into e2e/assets
|
||||
export const testAssetDir = path.resolve(`./../server/test/assets/`);
|
||||
export const tempDir = tmpdir();
|
||||
|
||||
const serverContainerName = 'immich-e2e-server';
|
||||
const mediaDir = '/usr/src/app/upload';
|
||||
const dirs = [
|
||||
`"${mediaDir}/thumbs"`,
|
||||
`"${mediaDir}/upload"`,
|
||||
`"${mediaDir}/library"`,
|
||||
`"${mediaDir}/encoded-video"`,
|
||||
].join(' ');
|
||||
|
||||
if (!(await directoryExists(`${testAssetDir}/albums`))) {
|
||||
throw new Error(
|
||||
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
|
||||
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`,
|
||||
);
|
||||
}
|
||||
|
||||
const setBaseUrl = () => (defaults.baseUrl = app);
|
||||
export const asBearerAuth = (accessToken: string) => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
@@ -44,24 +68,67 @@ export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
|
||||
let client: pg.Client | null = null;
|
||||
|
||||
export const dbUtils = {
|
||||
export const fileUtils = {
|
||||
reset: async () => {
|
||||
await execPromise(
|
||||
`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const dbUtils = {
|
||||
createFace: async ({
|
||||
assetId,
|
||||
personId,
|
||||
}: {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vector = Array.from({ length: 512 }, Math.random);
|
||||
const embedding = `[${vector.join(',')}]`;
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)',
|
||||
[assetId, personId, embedding],
|
||||
);
|
||||
},
|
||||
setPersonThumbnail: async (personId: string) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`,
|
||||
[personId],
|
||||
);
|
||||
},
|
||||
reset: async (tables?: string[]) => {
|
||||
try {
|
||||
if (!client) {
|
||||
client = new pg.Client(
|
||||
'postgres://postgres:postgres@127.0.0.1:5433/immich'
|
||||
'postgres://postgres:postgres@127.0.0.1:5433/immich',
|
||||
);
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
for (const table of [
|
||||
tables = tables || [
|
||||
'shared_links',
|
||||
'person',
|
||||
'albums',
|
||||
'assets',
|
||||
'asset_faces',
|
||||
'activity',
|
||||
'api_keys',
|
||||
'user_token',
|
||||
'users',
|
||||
'system_metadata',
|
||||
]) {
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
await client.query(`DELETE FROM ${table} CASCADE;`);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -115,10 +182,94 @@ export interface AdminSetupOptions {
|
||||
onboarding?: boolean;
|
||||
}
|
||||
|
||||
export enum SocketEvent {
|
||||
UPLOAD = 'upload',
|
||||
DELETE = 'delete',
|
||||
}
|
||||
|
||||
export type EventType = 'upload' | 'delete';
|
||||
export interface WaitOptions {
|
||||
event: EventType;
|
||||
assetId: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const events: Record<EventType, Set<string>> = {
|
||||
upload: new Set<string>(),
|
||||
delete: new Set<string>(),
|
||||
};
|
||||
|
||||
const callbacks: Record<string, () => void> = {};
|
||||
|
||||
const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
|
||||
events[event].add(assetId);
|
||||
const callback = callbacks[assetId];
|
||||
if (callback) {
|
||||
callback();
|
||||
delete callbacks[assetId];
|
||||
}
|
||||
};
|
||||
|
||||
export const wsUtils = {
|
||||
connect: async (accessToken: string) => {
|
||||
const websocket = io('http://127.0.0.1:2283', {
|
||||
path: '/api/socket.io',
|
||||
transports: ['websocket'],
|
||||
extraHeaders: { Authorization: `Bearer ${accessToken}` },
|
||||
autoConnect: true,
|
||||
forceNew: true,
|
||||
});
|
||||
|
||||
return new Promise<Socket>((resolve) => {
|
||||
websocket
|
||||
.on('connect', () => resolve(websocket))
|
||||
.on('on_upload_success', (data: AssetResponseDto) =>
|
||||
onEvent({ event: 'upload', assetId: data.id }),
|
||||
)
|
||||
.on('on_asset_delete', (assetId: string) =>
|
||||
onEvent({ event: 'delete', assetId }),
|
||||
)
|
||||
.connect();
|
||||
});
|
||||
},
|
||||
disconnect: (ws: Socket) => {
|
||||
if (ws?.connected) {
|
||||
ws.disconnect();
|
||||
}
|
||||
|
||||
for (const set of Object.values(events)) {
|
||||
set.clear();
|
||||
}
|
||||
},
|
||||
waitForEvent: async ({
|
||||
event,
|
||||
assetId,
|
||||
timeout: ms,
|
||||
}: WaitOptions): Promise<void> => {
|
||||
const set = events[event];
|
||||
if (set.has(assetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out waiting for ${event} event`)),
|
||||
ms || 5000,
|
||||
);
|
||||
|
||||
callbacks[assetId] = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const apiUtils = {
|
||||
setup: () => {
|
||||
setBaseUrl();
|
||||
defaults.baseUrl = app;
|
||||
},
|
||||
|
||||
adminSetup: async (options?: AdminSetupOptions) => {
|
||||
options = options || { onboarding: true };
|
||||
|
||||
@@ -132,7 +283,7 @@ export const apiUtils = {
|
||||
userSetup: async (accessToken: string, dto: CreateUserDto) => {
|
||||
await createUser(
|
||||
{ createUserDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
);
|
||||
return login({
|
||||
loginCredentialDto: { email: dto.email, password: dto.password },
|
||||
@@ -141,30 +292,75 @@ export const apiUtils = {
|
||||
createApiKey: (accessToken: string) => {
|
||||
return createApiKey(
|
||||
{ apiKeyCreateDto: { name: 'e2e' } },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
);
|
||||
},
|
||||
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
|
||||
createAlbum(
|
||||
{ createAlbumDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
),
|
||||
createAsset: async (
|
||||
accessToken: string,
|
||||
dto?: Omit<CreateAssetDto, 'assetData'>
|
||||
dto?: Partial<Omit<CreateAssetDto, 'assetData'>>,
|
||||
data?: {
|
||||
bytes?: Buffer;
|
||||
filename?: string;
|
||||
},
|
||||
) => {
|
||||
dto = dto || {
|
||||
const _dto = {
|
||||
deviceAssetId: 'test-1',
|
||||
deviceId: 'test',
|
||||
fileCreatedAt: new Date().toISOString(),
|
||||
fileModifiedAt: new Date().toISOString(),
|
||||
...(dto || {}),
|
||||
};
|
||||
const { body } = await request(app)
|
||||
|
||||
const _assetData = {
|
||||
bytes: randomBytes(32),
|
||||
filename: 'example.jpg',
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
const builder = request(app)
|
||||
.post(`/asset/upload`)
|
||||
.field('deviceAssetId', dto.deviceAssetId)
|
||||
.field('deviceId', dto.deviceId)
|
||||
.field('fileCreatedAt', dto.fileCreatedAt)
|
||||
.field('fileModifiedAt', dto.fileModifiedAt)
|
||||
.attach('assetData', randomBytes(32), 'example.jpg')
|
||||
.attach('assetData', _assetData.bytes, _assetData.filename)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
return body as AssetResponseDto;
|
||||
for (const [key, value] of Object.entries(_dto)) {
|
||||
builder.field(key, String(value));
|
||||
}
|
||||
|
||||
const { body } = await builder;
|
||||
|
||||
return body as AssetFileUploadResponseDto;
|
||||
},
|
||||
getAssetInfo: (accessToken: string, id: string) =>
|
||||
getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
||||
deleteAssets: (accessToken: string, ids: string[]) =>
|
||||
deleteAssets(
|
||||
{ assetBulkDeleteDto: { ids } },
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
),
|
||||
createPerson: async (accessToken: string, dto?: PersonUpdateDto) => {
|
||||
// TODO fix createPerson to accept a body
|
||||
let person = await createPerson({ headers: asBearerAuth(accessToken) });
|
||||
await dbUtils.setPersonThumbnail(person.id);
|
||||
|
||||
if (!dto) {
|
||||
return person;
|
||||
}
|
||||
|
||||
return updatePerson(
|
||||
{ id: person.id, personUpdateDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
);
|
||||
},
|
||||
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
|
||||
createSharedLink(
|
||||
{ sharedLinkCreateDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
),
|
||||
};
|
||||
|
||||
export const cliUtils = {
|
||||
|
||||
@@ -15,6 +15,7 @@ test.describe('Shared Links', () => {
|
||||
let asset: AssetResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let sharedLink: SharedLinkResponseDto;
|
||||
let sharedLinkPassword: SharedLinkResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
@@ -29,17 +30,16 @@ test.describe('Shared Links', () => {
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
// { headers: asBearerAuth(admin.accessToken)},
|
||||
);
|
||||
sharedLink = await createSharedLink(
|
||||
{
|
||||
sharedLinkCreateDto: {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
sharedLink = await apiUtils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
});
|
||||
sharedLinkPassword = await apiUtils.createSharedLink(admin.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
password: 'test-password',
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
@@ -55,4 +55,16 @@ test.describe('Shared Links', () => {
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
await page.getByText('DOWNLOADING').waitFor();
|
||||
});
|
||||
|
||||
test('enter password for a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLinkPassword.key}`);
|
||||
await page.getByPlaceholder('Password').fill('test-password');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
});
|
||||
|
||||
test('show error for invalid shared link', async ({ page }) => {
|
||||
await page.goto('/share/invalid');
|
||||
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .ann import Ann, is_available
|
||||
|
||||
@@ -32,8 +32,7 @@ T = TypeVar("T", covariant=True)
|
||||
|
||||
|
||||
class Newable(Protocol[T]):
|
||||
def new(self) -> None:
|
||||
...
|
||||
def new(self) -> None: ...
|
||||
|
||||
|
||||
class _Singleton(type, Newable[T]):
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from typing import Any
|
||||
|
||||
import onnx
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import snapshot_download
|
||||
from onnx.shape_inference import infer_shapes
|
||||
from onnx.tools.update_model_dims import update_inputs_outputs_dims
|
||||
|
||||
import ann.ann
|
||||
from app.models.constants import STATIC_INPUT_PROVIDERS, SUPPORTED_PROVIDERS
|
||||
from app.models.constants import SUPPORTED_PROVIDERS
|
||||
|
||||
from ..config import get_cache_dir, get_hf_model_name, log, settings
|
||||
from ..schemas import ModelRuntime, ModelType
|
||||
@@ -113,63 +111,25 @@ class InferenceModel(ABC):
|
||||
)
|
||||
model_path = onnx_path
|
||||
|
||||
if any(provider in STATIC_INPUT_PROVIDERS for provider in self.providers):
|
||||
static_path = model_path.parent / "static_1" / "model.onnx"
|
||||
static_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not static_path.is_file():
|
||||
self._convert_to_static(model_path, static_path)
|
||||
model_path = static_path
|
||||
|
||||
match model_path.suffix:
|
||||
case ".armnn":
|
||||
session = AnnSession(model_path)
|
||||
case ".onnx":
|
||||
session = ort.InferenceSession(
|
||||
model_path.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(model_path.parent)
|
||||
session = ort.InferenceSession(
|
||||
model_path.as_posix(),
|
||||
sess_options=self.sess_options,
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported model file type: {model_path.suffix}")
|
||||
return session
|
||||
|
||||
def _convert_to_static(self, source_path: Path, target_path: Path) -> None:
|
||||
inferred = infer_shapes(onnx.load(source_path))
|
||||
inputs = self._get_static_dims(inferred.graph.input)
|
||||
outputs = self._get_static_dims(inferred.graph.output)
|
||||
|
||||
# check_model gets called in update_inputs_outputs_dims and doesn't work for large models
|
||||
check_model = onnx.checker.check_model
|
||||
try:
|
||||
|
||||
def check_model_stub(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
onnx.checker.check_model = check_model_stub
|
||||
updated_model = update_inputs_outputs_dims(inferred, inputs, outputs)
|
||||
finally:
|
||||
onnx.checker.check_model = check_model
|
||||
|
||||
onnx.save(
|
||||
updated_model,
|
||||
target_path,
|
||||
save_as_external_data=True,
|
||||
all_tensors_to_one_file=False,
|
||||
size_threshold=1048576,
|
||||
)
|
||||
|
||||
def _get_static_dims(self, graph_io: Any, dim_size: int = 1) -> dict[str, list[int]]:
|
||||
return {
|
||||
field.name: [
|
||||
d.dim_value if d.HasField("dim_value") else dim_size
|
||||
for shape in field.type.ListFields()
|
||||
if (dim := shape[1].shape.dim)
|
||||
for d in dim
|
||||
]
|
||||
for field in graph_io
|
||||
}
|
||||
|
||||
@property
|
||||
def model_type(self) -> ModelType:
|
||||
return self._model_type
|
||||
@@ -205,6 +165,14 @@ class InferenceModel(ABC):
|
||||
def providers_default(self) -> list[str]:
|
||||
available_providers = set(ort.get_available_providers())
|
||||
log.debug(f"Available ORT providers: {available_providers}")
|
||||
if (openvino := "OpenVINOExecutionProvider") in available_providers:
|
||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||
|
||||
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||
if not gpu_devices:
|
||||
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
|
||||
available_providers.remove(openvino)
|
||||
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
||||
|
||||
@property
|
||||
@@ -224,15 +192,7 @@ class InferenceModel(ABC):
|
||||
case "CPUExecutionProvider" | "CUDAExecutionProvider":
|
||||
option = {"arena_extend_strategy": "kSameAsRequested"}
|
||||
case "OpenVINOExecutionProvider":
|
||||
try:
|
||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||
option = {"device_id": gpu_devices[0]} if gpu_devices else {}
|
||||
except AttributeError as e:
|
||||
log.warning("Failed to get OpenVINO device IDs. Using default options.")
|
||||
log.error(e)
|
||||
option = {}
|
||||
option = {"device_type": "GPU_FP32"}
|
||||
case _:
|
||||
option = {}
|
||||
options.append(option)
|
||||
|
||||
@@ -54,9 +54,6 @@ _INSIGHTFACE_MODELS = {
|
||||
SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"]
|
||||
|
||||
|
||||
STATIC_INPUT_PROVIDERS = ["OpenVINOExecutionProvider"]
|
||||
|
||||
|
||||
def is_openclip(model_name: str) -> bool:
|
||||
return clean_name(model_name) in _OPENCLIP_MODELS
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from random import randint
|
||||
@@ -44,11 +45,23 @@ class TestBase:
|
||||
assert encoder.providers == self.CUDA_EP
|
||||
|
||||
@pytest.mark.providers(OV_EP)
|
||||
def test_sets_openvino_provider_if_available(self, providers: list[str]) -> None:
|
||||
def test_sets_openvino_provider_if_available(self, providers: list[str], mocker: MockerFixture) -> None:
|
||||
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
|
||||
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
|
||||
assert encoder.providers == self.OV_EP
|
||||
|
||||
@pytest.mark.providers(OV_EP)
|
||||
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], mocker: MockerFixture) -> None:
|
||||
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
|
||||
mocked.get_available_openvino_device_ids.return_value = ["CPU"]
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
|
||||
assert encoder.providers == self.CPU_EP
|
||||
|
||||
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
||||
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
@@ -67,22 +80,14 @@ class TestBase:
|
||||
|
||||
assert encoder.providers == providers
|
||||
|
||||
def test_sets_default_provider_options(self) -> None:
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
|
||||
|
||||
assert encoder.provider_options == [
|
||||
{},
|
||||
{"arena_extend_strategy": "kSameAsRequested"},
|
||||
]
|
||||
|
||||
def test_sets_openvino_device_id_if_possible(self, mocker: MockerFixture) -> None:
|
||||
def test_sets_default_provider_options(self, mocker: MockerFixture) -> None:
|
||||
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
|
||||
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
|
||||
|
||||
assert encoder.provider_options == [
|
||||
{"device_id": "GPU.0"},
|
||||
{"device_type": "GPU_FP32"},
|
||||
{"arena_extend_strategy": "kSameAsRequested"},
|
||||
]
|
||||
|
||||
@@ -237,12 +242,12 @@ class TestBase:
|
||||
mock_model_path.is_file.return_value = True
|
||||
mock_model_path.suffix = ".armnn"
|
||||
mock_model_path.with_suffix.return_value = mock_model_path
|
||||
mock_session = mocker.patch("app.models.base.AnnSession")
|
||||
mock_ann = mocker.patch("app.models.base.AnnSession")
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
encoder._make_session(mock_model_path)
|
||||
|
||||
mock_session.assert_called_once()
|
||||
mock_ann.assert_called_once()
|
||||
|
||||
def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None:
|
||||
mock_armnn_path = mocker.Mock()
|
||||
@@ -256,6 +261,7 @@ class TestBase:
|
||||
|
||||
mock_ann = mocker.patch("app.models.base.AnnSession")
|
||||
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
|
||||
mocker.patch("app.models.base.os.chdir")
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
encoder._make_session(mock_armnn_path)
|
||||
@@ -278,6 +284,26 @@ class TestBase:
|
||||
mock_ann.assert_not_called()
|
||||
mock_ort.assert_not_called()
|
||||
|
||||
def test_make_session_changes_cwd(self, mocker: MockerFixture) -> None:
|
||||
mock_model_path = mocker.Mock()
|
||||
mock_model_path.is_file.return_value = True
|
||||
mock_model_path.suffix = ".onnx"
|
||||
mock_model_path.parent = "model_parent"
|
||||
mock_model_path.with_suffix.return_value = mock_model_path
|
||||
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
|
||||
mock_chdir = mocker.patch("app.models.base.os.chdir")
|
||||
|
||||
encoder = OpenCLIPEncoder("ViT-B-32__openai")
|
||||
encoder._make_session(mock_model_path)
|
||||
|
||||
mock_chdir.assert_has_calls(
|
||||
[
|
||||
mock.call(mock_model_path.parent),
|
||||
mock.call(os.getcwd()),
|
||||
]
|
||||
)
|
||||
mock_ort.assert_called_once()
|
||||
|
||||
def test_download(self, mocker: MockerFixture) -> None:
|
||||
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:926cac38640709f90f3fef2a3f730733b5c350be612f0d14706be8833b79ad8c as builder
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:6038b89363c9181215f3d9e8ce2720c880e224537f4028a854482e43a9b4998a as builder
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
TRANSFORMERS_CACHE=/cache \
|
||||
|
||||
424
machine-learning/poetry.lock
generated
424
machine-learning/poetry.lock
generated
@@ -64,33 +64,33 @@ trio = ["trio (>=0.23)"]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "24.1.1"
|
||||
version = "24.2.0"
|
||||
description = "The uncompromising code formatter."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"},
|
||||
{file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"},
|
||||
{file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"},
|
||||
{file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"},
|
||||
{file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"},
|
||||
{file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"},
|
||||
{file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"},
|
||||
{file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"},
|
||||
{file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"},
|
||||
{file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"},
|
||||
{file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"},
|
||||
{file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"},
|
||||
{file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"},
|
||||
{file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"},
|
||||
{file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"},
|
||||
{file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"},
|
||||
{file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"},
|
||||
{file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"},
|
||||
{file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"},
|
||||
{file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"},
|
||||
{file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"},
|
||||
{file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"},
|
||||
{file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"},
|
||||
{file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"},
|
||||
{file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"},
|
||||
{file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"},
|
||||
{file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"},
|
||||
{file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"},
|
||||
{file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"},
|
||||
{file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"},
|
||||
{file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"},
|
||||
{file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"},
|
||||
{file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"},
|
||||
{file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"},
|
||||
{file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"},
|
||||
{file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"},
|
||||
{file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"},
|
||||
{file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"},
|
||||
{file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"},
|
||||
{file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"},
|
||||
{file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"},
|
||||
{file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"},
|
||||
{file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"},
|
||||
{file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1250,13 +1250,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
description = "The next generation HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
|
||||
{file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
|
||||
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
|
||||
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2101,61 +2101,61 @@ numpy = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.9.13"
|
||||
version = "3.9.15"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "orjson-3.9.13-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fa6b67f8bef277c2a4aadd548d58796854e7d760964126c3209b19bccc6a74f1"},
|
||||
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b812417199eeb169c25f67815cfb66fd8de7ff098bf57d065e8c1943a7ba5c8f"},
|
||||
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ccd5bd222e5041069ad9d9868ab59e6dbc53ecde8d8c82b919954fbba43b46b"},
|
||||
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaaf80957c38e9d3f796f355a80fad945e72cd745e6b64c210e635b7043b673e"},
|
||||
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60da7316131185d0110a1848e9ad15311e6c8938ee0b5be8cbd7261e1d80ee8f"},
|
||||
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b98cd948372f0eb219bc309dee4633db1278687161e3280d9e693b6076951d2"},
|
||||
{file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3869d65561f10071d3e7f35ae58fd377056f67d7aaed5222f318390c3ad30339"},
|
||||
{file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43fd6036b16bb6742d03dae62f7bdf8214d06dea47e4353cde7e2bd1358d186f"},
|
||||
{file = "orjson-3.9.13-cp310-none-win32.whl", hash = "sha256:0d3ba9d88e20765335260d7b25547d7c571eee2b698200f97afa7d8c7cd668fc"},
|
||||
{file = "orjson-3.9.13-cp310-none-win_amd64.whl", hash = "sha256:6e47153db080f5e87e8ba638f1a8b18995eede6b0abb93964d58cf11bcea362f"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4584e8eb727bc431baaf1bf97e35a1d8a0109c924ec847395673dfd5f4ef6d6f"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f37f0cdd026ef777a4336e599d8194c8357fc14760c2a5ddcfdf1965d45504b"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d714595d81efab11b42bccd119977d94b25d12d3a806851ff6bfd286a4bce960"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9171e8e1a1f221953e38e84ae0abffe8759002fd8968106ee379febbb5358b33"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ab9dbdec3f13f3ea6f937564ce21651844cfbf2725099f2f490426acf683c23"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:811ac076855e33e931549340288e0761873baf29276ad00f221709933c644330"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:860d0f5b42d0c0afd73fa4177709f6e1b966ba691fcd72175affa902052a81d6"},
|
||||
{file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:838b898e8c1f26eb6b8d81b180981273f6f5110c76c22c384979aca854194f1b"},
|
||||
{file = "orjson-3.9.13-cp311-none-win32.whl", hash = "sha256:d3222db9df629ef3c3673124f2e05fb72bc4a320c117e953fec0d69dde82e36d"},
|
||||
{file = "orjson-3.9.13-cp311-none-win_amd64.whl", hash = "sha256:978117122ca4cc59b28af5322253017f6c5fc03dbdda78c7f4b94ae984c8dd43"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:031df1026c7ea8303332d78711f180231e3ae8b564271fb748a03926587c5546"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd9a2101d04e85086ea6198786a3f016e45475f800712e6833e14bf9ce2832f"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:446d9ad04204e79229ae19502daeea56479e55cbc32634655d886f5a39e91b44"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b57c0954a9fdd2b05b9cec0f5a12a0bdce5bf021a5b3b09323041613972481ab"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:266e55c83f81248f63cc93d11c5e3a53df49a5d2598fa9e9db5f99837a802d5d"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31372ba3a9fe8ad118e7d22fba46bbc18e89039e3bfa89db7bc8c18ee722dca8"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3b0c4da61f39899561e08e571f54472a09fa71717d9797928af558175ae5243"},
|
||||
{file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cc03a35bfc71c8ebf96ce49b82c2a7be6af4b3cd3ac34166fdb42ac510bbfff"},
|
||||
{file = "orjson-3.9.13-cp312-none-win_amd64.whl", hash = "sha256:49b7e3fe861cb246361825d1a238f2584ed8ea21e714bf6bb17cebb86772e61c"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:62e9a99879c4d5a04926ac2518a992134bfa00d546ea5a4cae4b9be454d35a22"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d92a3e835a5100f1d5b566fff79217eab92223ca31900dba733902a182a35ab0"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23f21faf072ed3b60b5954686f98157e073f6a8068eaa58dbde83e87212eda84"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:828c502bb261588f7de897e06cb23c4b122997cb039d2014cb78e7dabe92ef0c"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16946d095212a3dec552572c5d9bca7afa40f3116ad49695a397be07d529f1fa"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3deadd8dc0e9ff844b5b656fa30a48dbee1c3b332d8278302dd9637f6b09f627"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9b1b5adc5adf596c59dca57156b71ad301d73956f5bab4039b0e34dbf50b9fa0"},
|
||||
{file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ddc089315d030c54f0f03fb38286e2667c05009a78d659f108a8efcfbdf2e585"},
|
||||
{file = "orjson-3.9.13-cp38-none-win32.whl", hash = "sha256:ae77275a28667d9c82d4522b681504642055efa0368d73108511647c6499b31c"},
|
||||
{file = "orjson-3.9.13-cp38-none-win_amd64.whl", hash = "sha256:730385fdb99a21fce9bb84bb7fcbda72c88626facd74956bda712834b480729d"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7e8e4a571d958910272af8d53a9cbe6599f9f5fd496a1bc51211183bb2072cbd"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfad553a36548262e7da0f3a7464270e13900b898800fb571a5d4b298c3f8356"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d691c44604941945b00e0a13b19a7d9c1a19511abadf0080f373e98fdeb6b31"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8c83718346de08d68b3cb1105c5d91e5fc39885d8610fdda16613d4e3941459"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ef57a53bfc2091a7cd50a640d9ae866bd7d92a5225a1bab6baa60ef62583f2"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9156b96afa38db71344522f5517077eaedf62fcd2c9148392ff93d801128809c"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31fb66b41fb2c4c817d9610f0bc7d31345728d7b5295ac78b63603407432a2b2"},
|
||||
{file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8a730bf07feacb0863974e67b206b7c503a62199de1cece2eb0d4c233ec29c11"},
|
||||
{file = "orjson-3.9.13-cp39-none-win32.whl", hash = "sha256:5ef58869f3399acbbe013518d8b374ee9558659eef14bca0984f67cb1fbd3c37"},
|
||||
{file = "orjson-3.9.13-cp39-none-win_amd64.whl", hash = "sha256:9bcf56efdb83244cde070e82a69c0f03c47c235f0a5cb6c81d9da23af7fbaae4"},
|
||||
{file = "orjson-3.9.13.tar.gz", hash = "sha256:fc6bc65b0cf524ee042e0bc2912b9206ef242edfba7426cf95763e4af01f527a"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d61f7ce4727a9fa7680cd6f3986b0e2c732639f46a5e0156e550e35258aa313a"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4feeb41882e8aa17634b589533baafdceb387e01e117b1ec65534ec724023d04"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fbbeb3c9b2edb5fd044b2a070f127a0ac456ffd079cb82746fc84af01ef021a4"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b66bcc5670e8a6b78f0313bcb74774c8291f6f8aeef10fe70e910b8040f3ab75"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2973474811db7b35c30248d1129c64fd2bdf40d57d84beed2a9a379a6f57d0ab"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fe41b6f72f52d3da4db524c8653e46243c8c92df826ab5ffaece2dba9cccd58"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4228aace81781cc9d05a3ec3a6d2673a1ad0d8725b4e915f1089803e9efd2b99"},
|
||||
{file = "orjson-3.9.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f7b65bfaf69493c73423ce9db66cfe9138b2f9ef62897486417a8fcb0a92bfe"},
|
||||
{file = "orjson-3.9.15-cp310-none-win32.whl", hash = "sha256:2d99e3c4c13a7b0fb3792cc04c2829c9db07838fb6973e578b85c1745e7d0ce7"},
|
||||
{file = "orjson-3.9.15-cp310-none-win_amd64.whl", hash = "sha256:b725da33e6e58e4a5d27958568484aa766e825e93aa20c26c91168be58e08cbb"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c8e8fe01e435005d4421f183038fc70ca85d2c1e490f51fb972db92af6e047c2"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87f1097acb569dde17f246faa268759a71a2cb8c96dd392cd25c668b104cad2f"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff0f9913d82e1d1fadbd976424c316fbc4d9c525c81d047bbdd16bd27dd98cfc"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8055ec598605b0077e29652ccfe9372247474375e0e3f5775c91d9434e12d6b1"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6768a327ea1ba44c9114dba5fdda4a214bdb70129065cd0807eb5f010bfcbb5"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12365576039b1a5a47df01aadb353b68223da413e2e7f98c02403061aad34bde"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71c6b009d431b3839d7c14c3af86788b3cfac41e969e3e1c22f8a6ea13139404"},
|
||||
{file = "orjson-3.9.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e18668f1bd39e69b7fed19fa7cd1cd110a121ec25439328b5c89934e6d30d357"},
|
||||
{file = "orjson-3.9.15-cp311-none-win32.whl", hash = "sha256:62482873e0289cf7313461009bf62ac8b2e54bc6f00c6fabcde785709231a5d7"},
|
||||
{file = "orjson-3.9.15-cp311-none-win_amd64.whl", hash = "sha256:b3d336ed75d17c7b1af233a6561cf421dee41d9204aa3cfcc6c9c65cd5bb69a8"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:82425dd5c7bd3adfe4e94c78e27e2fa02971750c2b7ffba648b0f5d5cc016a73"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c51378d4a8255b2e7c1e5cc430644f0939539deddfa77f6fac7b56a9784160a"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae4e06be04dc00618247c4ae3f7c3e561d5bc19ab6941427f6d3722a0875ef7"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcef128f970bb63ecf9a65f7beafd9b55e3aaf0efc271a4154050fc15cdb386e"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b72758f3ffc36ca566ba98a8e7f4f373b6c17c646ff8ad9b21ad10c29186f00d"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c57bc7b946cf2efa67ac55766e41764b66d40cbd9489041e637c1304400494"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:946c3a1ef25338e78107fba746f299f926db408d34553b4754e90a7de1d44068"},
|
||||
{file = "orjson-3.9.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2f256d03957075fcb5923410058982aea85455d035607486ccb847f095442bda"},
|
||||
{file = "orjson-3.9.15-cp312-none-win_amd64.whl", hash = "sha256:5bb399e1b49db120653a31463b4a7b27cf2fbfe60469546baf681d1b39f4edf2"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b17f0f14a9c0ba55ff6279a922d1932e24b13fc218a3e968ecdbf791b3682b25"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f6cbd8e6e446fb7e4ed5bac4661a29e43f38aeecbf60c4b900b825a353276a1"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76bc6356d07c1d9f4b782813094d0caf1703b729d876ab6a676f3aaa9a47e37c"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdfa97090e2d6f73dced247a2f2d8004ac6449df6568f30e7fa1a045767c69a6"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7413070a3e927e4207d00bd65f42d1b780fb0d32d7b1d951f6dc6ade318e1b5a"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cf1596680ac1f01839dba32d496136bdd5d8ffb858c280fa82bbfeb173bdd40"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:809d653c155e2cc4fd39ad69c08fdff7f4016c355ae4b88905219d3579e31eb7"},
|
||||
{file = "orjson-3.9.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:920fa5a0c5175ab14b9c78f6f820b75804fb4984423ee4c4f1e6d748f8b22bc1"},
|
||||
{file = "orjson-3.9.15-cp38-none-win32.whl", hash = "sha256:2b5c0f532905e60cf22a511120e3719b85d9c25d0e1c2a8abb20c4dede3b05a5"},
|
||||
{file = "orjson-3.9.15-cp38-none-win_amd64.whl", hash = "sha256:67384f588f7f8daf040114337d34a5188346e3fae6c38b6a19a2fe8c663a2f9b"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6fc2fe4647927070df3d93f561d7e588a38865ea0040027662e3e541d592811e"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34cbcd216e7af5270f2ffa63a963346845eb71e174ea530867b7443892d77180"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f541587f5c558abd93cb0de491ce99a9ef8d1ae29dd6ab4dbb5a13281ae04cbd"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92255879280ef9c3c0bcb327c5a1b8ed694c290d61a6a532458264f887f052cb"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:05a1f57fb601c426635fcae9ddbe90dfc1ed42245eb4c75e4960440cac667262"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ede0bde16cc6e9b96633df1631fbcd66491d1063667f260a4f2386a098393790"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e88b97ef13910e5f87bcbc4dd7979a7de9ba8702b54d3204ac587e83639c0c2b"},
|
||||
{file = "orjson-3.9.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57d5d8cf9c27f7ef6bc56a5925c7fbc76b61288ab674eb352c26ac780caa5b10"},
|
||||
{file = "orjson-3.9.15-cp39-none-win32.whl", hash = "sha256:001f4eb0ecd8e9ebd295722d0cbedf0748680fb9998d3993abaed2f40587257a"},
|
||||
{file = "orjson-3.9.15-cp39-none-win_amd64.whl", hash = "sha256:ea0b183a5fe6b2b45f3b854b0d19c4e932d6f5934ae1f723b07cf9560edd4ec7"},
|
||||
{file = "orjson-3.9.15.tar.gz", hash = "sha256:95cae920959d772f30ab36d3b25f83bb0f3be671e986c72ce22f8fa700dae061"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2465,13 +2465,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.0.0"
|
||||
version = "8.0.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"},
|
||||
{file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"},
|
||||
{file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"},
|
||||
{file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2836,28 +2836,28 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"},
|
||||
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"},
|
||||
{file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"},
|
||||
{file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"},
|
||||
{file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"},
|
||||
{file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"},
|
||||
{file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"},
|
||||
{file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"},
|
||||
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"},
|
||||
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"},
|
||||
{file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"},
|
||||
{file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"},
|
||||
{file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"},
|
||||
{file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"},
|
||||
{file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"},
|
||||
{file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3096,121 +3096,121 @@ all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib"
|
||||
|
||||
[[package]]
|
||||
name = "tokenizers"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:32c9491dd1bcb33172c26b454dbd607276af959b9e78fa766e2694cafab3103c"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29a1b784b870a097e7768f8c20c2dd851e2c75dad3efdae69a79d3e7f1d614d5"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0049fbe648af04148b08cb211994ce8365ee628ce49724b56aaefd09a3007a78"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e84b3c235219e75e24de6b71e6073cd2c8d740b14d88e4c6d131b90134e3a338"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8cc575769ea11d074308c6d71cb10b036cdaec941562c07fc7431d956c502f0e"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bf28f299c4158e6d0b5eaebddfd500c4973d947ffeaca8bcbe2e8c137dff0b"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:506555f98361db9c74e1323a862d77dcd7d64c2058829a368bf4159d986e339f"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7061b0a28ade15906f5b2ec8c48d3bdd6e24eca6b427979af34954fbe31d5cef"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ed5e35507b7a0e2aac3285c4f5e37d4ec5cfc0e5825b862b68a0aaf2757af52"},
|
||||
{file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c9df9247df0de6509dd751b1c086e5f124b220133b5c883bb691cb6fb3d786f"},
|
||||
{file = "tokenizers-0.15.1-cp310-none-win32.whl", hash = "sha256:dd999af1b4848bef1b11d289f04edaf189c269d5e6afa7a95fa1058644c3f021"},
|
||||
{file = "tokenizers-0.15.1-cp310-none-win_amd64.whl", hash = "sha256:39d06a57f7c06940d602fad98702cf7024c4eee7f6b9fe76b9f2197d5a4cc7e2"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8ad034eb48bf728af06915e9294871f72fcc5254911eddec81d6df8dba1ce055"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea9ede7c42f8fa90f31bfc40376fd91a7d83a4aa6ad38e6076de961d48585b26"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b85d6fe1a20d903877aa0ef32ef6b96e81e0e48b71c206d6046ce16094de6970"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a7d44f656320137c7d643b9c7dcc1814763385de737fb98fd2643880910f597"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd244bd0793cdacf27ee65ec3db88c21f5815460e8872bbeb32b040469d6774e"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3f4a36e371b3cb1123adac8aeeeeab207ad32f15ed686d9d71686a093bb140"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2921a53966afb29444da98d56a6ccbef23feb3b0c0f294b4e502370a0a64f25"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f49068cf51f49c231067f1a8c9fc075ff960573f6b2a956e8e1b0154fb638ea5"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0ab1a22f20eaaab832ab3b00a0709ca44a0eb04721e580277579411b622c741c"},
|
||||
{file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:671268f24b607c4adc6fa2b5b580fd4211b9f84b16bd7f46d62f8e5be0aa7ba4"},
|
||||
{file = "tokenizers-0.15.1-cp311-none-win32.whl", hash = "sha256:a4f03e33d2bf7df39c8894032aba599bf90f6f6378e683a19d28871f09bb07fc"},
|
||||
{file = "tokenizers-0.15.1-cp311-none-win_amd64.whl", hash = "sha256:30f689537bcc7576d8bd4daeeaa2cb8f36446ba2f13f421b173e88f2d8289c4e"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f3a379dd0898a82ea3125e8f9c481373f73bffce6430d4315f0b6cd5547e409"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d870ae58bba347d38ac3fc8b1f662f51e9c95272d776dd89f30035c83ee0a4f"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d6d28e0143ec2e253a8a39e94bf1d24776dbe73804fa748675dbffff4a5cd6d8"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61ae9ac9f44e2da128ee35db69489883b522f7abe033733fa54eb2de30dac23d"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8e322a47e29128300b3f7749a03c0ec2bce0a3dc8539ebff738d3f59e233542"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:760334f475443bc13907b1a8e1cb0aeaf88aae489062546f9704dce6c498bfe2"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b173753d4aca1e7d0d4cb52b5e3ffecfb0ca014e070e40391b6bb4c1d6af3f2"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1f13d457c8f0ab17e32e787d03470067fe8a3b4d012e7cc57cb3264529f4a"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:425b46ceff4505f20191df54b50ac818055d9d55023d58ae32a5d895b6f15bb0"},
|
||||
{file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:681ac6ba3b4fdaf868ead8971221a061f580961c386e9732ea54d46c7b72f286"},
|
||||
{file = "tokenizers-0.15.1-cp312-none-win32.whl", hash = "sha256:f2272656063ccfba2044df2115095223960d80525d208e7a32f6c01c351a6f4a"},
|
||||
{file = "tokenizers-0.15.1-cp312-none-win_amd64.whl", hash = "sha256:9abe103203b1c6a2435d248d5ff4cceebcf46771bfbc4957a98a74da6ed37674"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2ce9ed5c8ef26b026a66110e3c7b73d93ec2d26a0b1d0ea55ddce61c0e5f446f"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89b24d366137986c3647baac29ef902d2d5445003d11c30df52f1bd304689aeb"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0faebedd01b413ab777ca0ee85914ed8b031ea5762ab0ea60b707ce8b9be6842"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdbd9dfcdad4f3b95d801f768e143165165055c18e44ca79a8a26de889cd8e85"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97194324c12565b07e9993ca9aa813b939541185682e859fb45bb8d7d99b3193"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485e43e2cc159580e0d83fc919ec3a45ae279097f634b1ffe371869ffda5802c"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191d084d60e3589d6420caeb3f9966168269315f8ec7fbc3883122dc9d99759d"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01c28cc8d7220634a75b14c53f4fc9d1b485f99a5a29306a999c115921de2897"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:325212027745d3f8d5d5006bb9e5409d674eb80a184f19873f4f83494e1fdd26"},
|
||||
{file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3c5573603c36ce12dbe318bcfb490a94cad2d250f34deb2f06cb6937957bbb71"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:1441161adb6d71a15a630d5c1d8659d5ebe41b6b209586fbeea64738e58fcbb2"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:382a8d0c31afcfb86571afbfefa37186df90865ce3f5b731842dab4460e53a38"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e76959783e3f4ec73b3f3d24d4eec5aa9225f0bee565c48e77f806ed1e048f12"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:401df223e5eb927c5961a0fc6b171818a2bba01fb36ef18c3e1b69b8cd80e591"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52606c233c759561a16e81b2290a7738c3affac7a0b1f0a16fe58dc22e04c7d"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72c658bbe5a05ed8bc2ac5ad782385bfd743ffa4bc87d9b5026341e709c6f44"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25f5643a2f005c42f0737a326c6c6bdfedfdc9a994b10a1923d9c3e792e4d6a6"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5b6f633999d6b42466bbfe21be2e26ad1760b6f106967a591a41d8cbca980e"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ceb5c9ad11a015150b545c1a11210966a45b8c3d68a942e57cf8938c578a77ca"},
|
||||
{file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bedd4ce0c4872db193444c395b11c7697260ce86a635ab6d48102d76be07d324"},
|
||||
{file = "tokenizers-0.15.1-cp37-none-win32.whl", hash = "sha256:cd6caef6c14f5ed6d35f0ddb78eab8ca6306d0cd9870330bccff72ad014a6f42"},
|
||||
{file = "tokenizers-0.15.1-cp37-none-win_amd64.whl", hash = "sha256:d2bd7af78f58d75a55e5df61efae164ab9200c04b76025f9cc6eeb7aff3219c2"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:59b3ca6c02e0bd5704caee274978bd055de2dff2e2f39dadf536c21032dfd432"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:48fe21b67c22583bed71933a025fd66b1f5cfae1baefa423c3d40379b5a6e74e"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3d190254c66a20fb1efbdf035e6333c5e1f1c73b1f7bfad88f9c31908ac2c2c4"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef90c8f5abf17d48d6635f5fd92ad258acd1d0c2d920935c8bf261782cfe7c8"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fac011ef7da3357aa7eb19efeecf3d201ede9618f37ddedddc5eb809ea0963ca"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:574ec5b3e71d1feda6b0ecac0e0445875729b4899806efbe2b329909ec75cb50"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aca16c3c0637c051a59ea99c4253f16fbb43034fac849076a7e7913b2b9afd2d"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6f238fc2bbfd3e12e8529980ec1624c7e5b69d4e959edb3d902f36974f725a"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:587e11a26835b73c31867a728f32ca8a93c9ded4a6cd746516e68b9d51418431"},
|
||||
{file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6456e7ad397352775e2efdf68a9ec5d6524bbc4543e926eef428d36de627aed4"},
|
||||
{file = "tokenizers-0.15.1-cp38-none-win32.whl", hash = "sha256:614f0da7dd73293214bd143e6221cafd3f7790d06b799f33a987e29d057ca658"},
|
||||
{file = "tokenizers-0.15.1-cp38-none-win_amd64.whl", hash = "sha256:a4fa0a20d9f69cc2bf1cfce41aa40588598e77ec1d6f56bf0eb99769969d1ede"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8d3f18a45e0cf03ce193d5900460dc2430eec4e14c786e5d79bddba7ea19034f"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:38dbd6c38f88ad7d5dc5d70c764415d38fe3bcd99dc81638b572d093abc54170"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:777286b1f7e52de92aa4af49fe31046cfd32885d1bbaae918fab3bba52794c33"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d4d550a3862a47dd249892d03a025e32286eb73cbd6bc887fb8fb64bc97165"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eda68ce0344f35042ae89220b40a0007f721776b727806b5c95497b35714bb7"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cd33d15f7a3a784c3b665cfe807b8de3c6779e060349bd5005bb4ae5bdcb437"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a1aa370f978ac0bfb50374c3a40daa93fd56d47c0c70f0c79607fdac2ccbb42"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:241482b940340fff26a2708cb9ba383a5bb8a2996d67a0ff2c4367bf4b86cc3a"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:68f30b05f46a4d9aba88489eadd021904afe90e10a7950e28370d6e71b9db021"},
|
||||
{file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5a3c5d8025529670462b881b7b2527aacb6257398c9ec8e170070432c3ae3a82"},
|
||||
{file = "tokenizers-0.15.1-cp39-none-win32.whl", hash = "sha256:74d1827830f60a9d78da8f6d49a1fbea5422ce0eea42e2617877d23380a7efbc"},
|
||||
{file = "tokenizers-0.15.1-cp39-none-win_amd64.whl", hash = "sha256:9ff499923e4d6876d6b6a63ea84a56805eb35e91dd89b933a7aee0c56a3838c6"},
|
||||
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b3aa007a0f4408f62a8471bdaa3faccad644cbf2622639f2906b4f9b5339e8b8"},
|
||||
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f3d4176fa93d8b2070db8f3c70dc21106ae6624fcaaa334be6bdd3a0251e729e"},
|
||||
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d0e463655ef8b2064df07bd4a445ed7f76f6da3b286b4590812587d42f80e89"},
|
||||
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:089138fd0351b62215c462a501bd68b8df0e213edcf99ab9efd5dba7b4cb733e"},
|
||||
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e563ac628f5175ed08e950430e2580e544b3e4b606a0995bb6b52b3a3165728"},
|
||||
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:244dcc28c5fde221cb4373961b20da30097669005b122384d7f9f22752487a46"},
|
||||
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d82951d46052dddae1369e68ff799a0e6e29befa9a0b46e387ae710fd4daefb0"},
|
||||
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b14296bc9059849246ceb256ffbe97f8806a9b5d707e0095c22db312f4fc014"},
|
||||
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0309357bb9b6c8d86cdf456053479d7112074b470651a997a058cd7ad1c4ea57"},
|
||||
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083f06e9d8d01b70b67bcbcb7751b38b6005512cce95808be6bf34803534a7e7"},
|
||||
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85288aea86ada579789447f0dcec108ebef8da4b450037eb4813d83e4da9371e"},
|
||||
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:385e6fcb01e8de90c1d157ae2a5338b23368d0b1c4cc25088cdca90147e35d17"},
|
||||
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:60067edfcbf7d6cd448ac47af41ec6e84377efbef7be0c06f15a7c1dd069e044"},
|
||||
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f7e37f89acfe237d4eaf93c3b69b0f01f407a7a5d0b5a8f06ba91943ea3cf10"},
|
||||
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:6a63a15b523d42ebc1f4028e5a568013388c2aefa4053a263e511cb10aaa02f1"},
|
||||
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2417d9e4958a6c2fbecc34c27269e74561c55d8823bf914b422e261a11fdd5fd"},
|
||||
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8550974bace6210e41ab04231e06408cf99ea4279e0862c02b8d47e7c2b2828"},
|
||||
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:194ba82129b171bcd29235a969e5859a93e491e9b0f8b2581f500f200c85cfdd"},
|
||||
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1bfd95eef8b01e6c0805dbccc8eaf41d8c5a84f0cce72c0ab149fe76aae0bce6"},
|
||||
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b87a15dd72f8216b03c151e3dace00c75c3fe7b0ee9643c25943f31e582f1a34"},
|
||||
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6ac22f358a0c2a6c685be49136ce7ea7054108986ad444f567712cf274b34cd8"},
|
||||
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e9d1f046a9b9d9a95faa103f07db5921d2c1c50f0329ebba4359350ee02b18b"},
|
||||
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a0fd30a4b74485f6a7af89fffb5fb84d6d5f649b3e74f8d37f624cc9e9e97cf"},
|
||||
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e45dc206b9447fa48795a1247c69a1732d890b53e2cc51ba42bc2fefa22407"},
|
||||
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eaff56ef3e218017fa1d72007184401f04cb3a289990d2b6a0a76ce71c95f96"},
|
||||
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b41dc107e4a4e9c95934e79b025228bbdda37d9b153d8b084160e88d5e48ad6f"},
|
||||
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1922b8582d0c33488764bcf32e80ef6054f515369e70092729c928aae2284bc2"},
|
||||
{file = "tokenizers-0.15.1.tar.gz", hash = "sha256:c0a331d6d5a3d6e97b7f99f562cee8d56797180797bc55f12070e495e717c980"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:52f6130c9cbf70544287575a985bf44ae1bda2da7e8c24e97716080593638012"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:054c1cc9c6d68f7ffa4e810b3d5131e0ba511b6e4be34157aa08ee54c2f8d9ee"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9b9b070fdad06e347563b88c278995735292ded1132f8657084989a4c84a6d5"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea621a7eef4b70e1f7a4e84dd989ae3f0eeb50fc8690254eacc08acb623e82f1"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7fd9a5141634fa3aa8d6b7be362e6ae1b4cda60da81388fa533e0b552c98fd"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44f2a832cd0825295f7179eaf173381dc45230f9227ec4b44378322d900447c9"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b9ec69247a23747669ec4b0ca10f8e3dfb3545d550258129bd62291aabe8605"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b6a4c78da863ff26dbd5ad9a8ecc33d8a8d97b535172601cf00aee9d7ce9ce"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5ab2a4d21dcf76af60e05af8063138849eb1d6553a0d059f6534357bce8ba364"},
|
||||
{file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a47acfac7e511f6bbfcf2d3fb8c26979c780a91e06fb5b9a43831b2c0153d024"},
|
||||
{file = "tokenizers-0.15.2-cp310-none-win32.whl", hash = "sha256:064ff87bb6acdbd693666de9a4b692add41308a2c0ec0770d6385737117215f2"},
|
||||
{file = "tokenizers-0.15.2-cp310-none-win_amd64.whl", hash = "sha256:3b919afe4df7eb6ac7cafd2bd14fb507d3f408db7a68c43117f579c984a73843"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:89cd1cb93e4b12ff39bb2d626ad77e35209de9309a71e4d3d4672667b4b256e7"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfed5c64e5be23d7ee0f0e98081a25c2a46b0b77ce99a4f0605b1ec43dd481fa"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a907d76dcfda37023ba203ab4ceeb21bc5683436ebefbd895a0841fd52f6f6f2"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ea60479de6fc7b8ae756b4b097572372d7e4032e2521c1bbf3d90c90a99ff0"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48e2b9335be2bc0171df9281385c2ed06a15f5cf121c44094338306ab7b33f2c"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:112a1dd436d2cc06e6ffdc0b06d55ac019a35a63afd26475205cb4b1bf0bfbff"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4620cca5c2817177ee8706f860364cc3a8845bc1e291aaf661fb899e5d1c45b0"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccd73a82751c523b3fc31ff8194702e4af4db21dc20e55b30ecc2079c5d43cb7"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:107089f135b4ae7817affe6264f8c7a5c5b4fd9a90f9439ed495f54fcea56fb4"},
|
||||
{file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0ff110ecc57b7aa4a594396525a3451ad70988e517237fe91c540997c4e50e29"},
|
||||
{file = "tokenizers-0.15.2-cp311-none-win32.whl", hash = "sha256:6d76f00f5c32da36c61f41c58346a4fa7f0a61be02f4301fd30ad59834977cc3"},
|
||||
{file = "tokenizers-0.15.2-cp311-none-win_amd64.whl", hash = "sha256:cc90102ed17271cf0a1262babe5939e0134b3890345d11a19c3145184b706055"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f86593c18d2e6248e72fb91c77d413a815153b8ea4e31f7cd443bdf28e467670"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0774bccc6608eca23eb9d620196687c8b2360624619623cf4ba9dc9bd53e8b51"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0222c5b7c9b26c0b4822a82f6a7011de0a9d3060e1da176f66274b70f846b98"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3835738be1de66624fff2f4f6f6684775da4e9c00bde053be7564cbf3545cc66"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0143e7d9dcd811855c1ce1ab9bf5d96d29bf5e528fd6c7824d0465741e8c10fd"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db35825f6d54215f6b6009a7ff3eedee0848c99a6271c870d2826fbbedf31a38"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f5e64b0389a2be47091d8cc53c87859783b837ea1a06edd9d8e04004df55a5c"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e0480c452217edd35eca56fafe2029fb4d368b7c0475f8dfa3c5c9c400a7456"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a33ab881c8fe70474980577e033d0bc9a27b7ab8272896e500708b212995d834"},
|
||||
{file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a308a607ca9de2c64c1b9ba79ec9a403969715a1b8ba5f998a676826f1a7039d"},
|
||||
{file = "tokenizers-0.15.2-cp312-none-win32.whl", hash = "sha256:b8fcfa81bcb9447df582c5bc96a031e6df4da2a774b8080d4f02c0c16b42be0b"},
|
||||
{file = "tokenizers-0.15.2-cp312-none-win_amd64.whl", hash = "sha256:38d7ab43c6825abfc0b661d95f39c7f8af2449364f01d331f3b51c94dcff7221"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:38bfb0204ff3246ca4d5e726e8cc8403bfc931090151e6eede54d0e0cf162ef0"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c861d35e8286a53e06e9e28d030b5a05bcbf5ac9d7229e561e53c352a85b1fc"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:936bf3842db5b2048eaa53dade907b1160f318e7c90c74bfab86f1e47720bdd6"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:620beacc3373277700d0e27718aa8b25f7b383eb8001fba94ee00aeea1459d89"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2735ecbbf37e52db4ea970e539fd2d450d213517b77745114f92867f3fc246eb"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:473c83c5e2359bb81b0b6fde870b41b2764fcdd36d997485e07e72cc3a62264a"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968fa1fb3c27398b28a4eca1cbd1e19355c4d3a6007f7398d48826bbe3a0f728"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:865c60ae6eaebdde7da66191ee9b7db52e542ed8ee9d2c653b6d190a9351b980"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7c0d8b52664ab2d4a8d6686eb5effc68b78608a9008f086a122a7b2996befbab"},
|
||||
{file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f33dfbdec3784093a9aebb3680d1f91336c56d86cc70ddf88708251da1fe9064"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:d44ba80988ff9424e33e0a49445072ac7029d8c0e1601ad25a0ca5f41ed0c1d6"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:dce74266919b892f82b1b86025a613956ea0ea62a4843d4c4237be2c5498ed3a"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0ef06b9707baeb98b316577acb04f4852239d856b93e9ec3a299622f6084e4be"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73e2e74bbb07910da0d37c326869f34113137b23eadad3fc00856e6b3d9930c"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eeb12daf02a59e29f578a865f55d87cd103ce62bd8a3a5874f8fdeaa82e336b"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ba9f6895af58487ca4f54e8a664a322f16c26bbb442effd01087eba391a719e"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccec77aa7150e38eec6878a493bf8c263ff1fa8a62404e16c6203c64c1f16a26"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f40604f5042ff210ba82743dda2b6aa3e55aa12df4e9f2378ee01a17e2855e"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5645938a42d78c4885086767c70923abad047163d809c16da75d6b290cb30bbe"},
|
||||
{file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:05a77cbfebe28a61ab5c3891f9939cc24798b63fa236d84e5f29f3a85a200c00"},
|
||||
{file = "tokenizers-0.15.2-cp37-none-win32.whl", hash = "sha256:361abdc068e8afe9c5b818769a48624687fb6aaed49636ee39bec4e95e1a215b"},
|
||||
{file = "tokenizers-0.15.2-cp37-none-win_amd64.whl", hash = "sha256:7ef789f83eb0f9baeb4d09a86cd639c0a5518528f9992f38b28e819df397eb06"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4fe1f74a902bee74a3b25aff180fbfbf4f8b444ab37c4d496af7afd13a784ed2"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c4b89038a684f40a6b15d6b09f49650ac64d951ad0f2a3ea9169687bbf2a8ba"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d05a1b06f986d41aed5f2de464c003004b2df8aaf66f2b7628254bcbfb72a438"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:508711a108684111ec8af89d3a9e9e08755247eda27d0ba5e3c50e9da1600f6d"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daa348f02d15160cb35439098ac96e3a53bacf35885072611cd9e5be7d333daa"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:494fdbe5932d3416de2a85fc2470b797e6f3226c12845cadf054dd906afd0442"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2d60f5246f4da9373f75ff18d64c69cbf60c3bca597290cea01059c336d2470"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93268e788825f52de4c7bdcb6ebc1fcd4a5442c02e730faa9b6b08f23ead0e24"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6fc7083ab404019fc9acafe78662c192673c1e696bd598d16dc005bd663a5cf9"},
|
||||
{file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e39b41e5531d6b2122a77532dbea60e171ef87a3820b5a3888daa847df4153"},
|
||||
{file = "tokenizers-0.15.2-cp38-none-win32.whl", hash = "sha256:06cd0487b1cbfabefb2cc52fbd6b1f8d4c37799bd6c6e1641281adaa6b2504a7"},
|
||||
{file = "tokenizers-0.15.2-cp38-none-win_amd64.whl", hash = "sha256:5179c271aa5de9c71712e31cb5a79e436ecd0d7532a408fa42a8dbfa4bc23fd9"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82f8652a74cc107052328b87ea8b34291c0f55b96d8fb261b3880216a9f9e48e"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02458bee6f5f3139f1ebbb6d042b283af712c0981f5bc50edf771d6b762d5e4f"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9a09cd26cca2e1c349f91aa665309ddb48d71636370749414fbf67bc83c5343"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158be8ea8554e5ed69acc1ce3fbb23a06060bd4bbb09029431ad6b9a466a7121"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ddba9a2b0c8c81633eca0bb2e1aa5b3a15362b1277f1ae64176d0f6eba78ab1"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ef5dd1d39797044642dbe53eb2bc56435308432e9c7907728da74c69ee2adca"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:454c203164e07a860dbeb3b1f4a733be52b0edbb4dd2e5bd75023ffa8b49403a"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cf6b7f1d4dc59af960e6ffdc4faffe6460bbfa8dce27a58bf75755ffdb2526d"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2ef09bbc16519f6c25d0c7fc0c6a33a6f62923e263c9d7cca4e58b8c61572afb"},
|
||||
{file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c9a2ebdd2ad4ec7a68e7615086e633857c85e2f18025bd05d2a4399e6c5f7169"},
|
||||
{file = "tokenizers-0.15.2-cp39-none-win32.whl", hash = "sha256:918fbb0eab96fe08e72a8c2b5461e9cce95585d82a58688e7f01c2bd546c79d0"},
|
||||
{file = "tokenizers-0.15.2-cp39-none-win_amd64.whl", hash = "sha256:524e60da0135e106b254bd71f0659be9f89d83f006ea9093ce4d1fab498c6d0d"},
|
||||
{file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6a9b648a58281c4672212fab04e60648fde574877d0139cd4b4f93fe28ca8944"},
|
||||
{file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7c7d18b733be6bbca8a55084027f7be428c947ddf871c500ee603e375013ffba"},
|
||||
{file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:13ca3611de8d9ddfbc4dc39ef54ab1d2d4aaa114ac8727dfdc6a6ec4be017378"},
|
||||
{file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:237d1bf3361cf2e6463e6c140628e6406766e8b27274f5fcc62c747ae3c6f094"},
|
||||
{file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67a0fe1e49e60c664915e9fb6b0cb19bac082ab1f309188230e4b2920230edb3"},
|
||||
{file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4e022fe65e99230b8fd89ebdfea138c24421f91c1a4f4781a8f5016fd5cdfb4d"},
|
||||
{file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d857be2df69763362ac699f8b251a8cd3fac9d21893de129bc788f8baaef2693"},
|
||||
{file = "tokenizers-0.15.2-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:708bb3e4283177236309e698da5fcd0879ce8fd37457d7c266d16b550bcbbd18"},
|
||||
{file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c35e09e9899b72a76e762f9854e8750213f67567787d45f37ce06daf57ca78"},
|
||||
{file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1257f4394be0d3b00de8c9e840ca5601d0a4a8438361ce9c2b05c7d25f6057b"},
|
||||
{file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02272fe48280e0293a04245ca5d919b2c94a48b408b55e858feae9618138aeda"},
|
||||
{file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dc3ad9ebc76eabe8b1d7c04d38be884b8f9d60c0cdc09b0aa4e3bcf746de0388"},
|
||||
{file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:32e16bdeffa7c4f46bf2152172ca511808b952701d13e7c18833c0b73cb5c23f"},
|
||||
{file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fb16ba563d59003028b678d2361a27f7e4ae0ab29c7a80690efa20d829c81fdb"},
|
||||
{file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:2277c36d2d6cdb7876c274547921a42425b6810d38354327dd65a8009acf870c"},
|
||||
{file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1cf75d32e8d250781940d07f7eece253f2fe9ecdb1dc7ba6e3833fa17b82fcbc"},
|
||||
{file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b3b31884dc8e9b21508bb76da80ebf7308fdb947a17affce815665d5c4d028"},
|
||||
{file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10122d8d8e30afb43bb1fe21a3619f62c3e2574bff2699cf8af8b0b6c5dc4a3"},
|
||||
{file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d88b96ff0fe8e91f6ef01ba50b0d71db5017fa4e3b1d99681cec89a85faf7bf7"},
|
||||
{file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:37aaec5a52e959892870a7c47cef80c53797c0db9149d458460f4f31e2fb250e"},
|
||||
{file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2ea752f2b0fe96eb6e2f3adbbf4d72aaa1272079b0dfa1145507bd6a5d537e6"},
|
||||
{file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b19a808d8799fda23504a5cd31d2f58e6f52f140380082b352f877017d6342b"},
|
||||
{file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c86e5e068ac8b19204419ed8ca90f9d25db20578f5881e337d203b314f4104"},
|
||||
{file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de19c4dc503c612847edf833c82e9f73cd79926a384af9d801dcf93f110cea4e"},
|
||||
{file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea09acd2fe3324174063d61ad620dec3bcf042b495515f27f638270a7d466e8b"},
|
||||
{file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cf27fd43472e07b57cf420eee1e814549203d56de00b5af8659cb99885472f1f"},
|
||||
{file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7ca22bd897537a0080521445d91a58886c8c04084a6a19e6c78c586e0cfa92a5"},
|
||||
{file = "tokenizers-0.15.2.tar.gz", hash = "sha256:e6e9c6e019dd5484be5beafc775ae6c925f4c69a3487040ed09b45e13df2cb91"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3281,13 +3281,13 @@ zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.27.0.post1"
|
||||
version = "0.27.1"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f"},
|
||||
{file = "uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907"},
|
||||
{file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"},
|
||||
{file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.95.1"
|
||||
version = "1.97.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
@@ -82,10 +82,10 @@ warn_untyped_fields = true
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"test_main.py" = ["F403"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
per-file-ignores = { "test_main.py" = ["F403"] }
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 123,
|
||||
"android.injected.version.name" => "1.95.1",
|
||||
"android.injected.version.code" => 125,
|
||||
"android.injected.version.name" => "1.97.0",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000232">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000271">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="78.881681">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="74.334294">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="32.080999">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.507669">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 139;
|
||||
CURRENT_PROJECT_VERSION = 140;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -515,7 +515,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 139;
|
||||
CURRENT_PROJECT_VERSION = 140;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -543,7 +543,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 139;
|
||||
CURRENT_PROJECT_VERSION = 140;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -55,11 +55,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.95.0</string>
|
||||
<string>1.96.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>139</string>
|
||||
<string>140</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.95.1"
|
||||
version_number: "1.97.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000255">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.157832">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.190055">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.825919">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.109364">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.18815">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.15926">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="110.912709">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="80.90681">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="78.396901">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="71.634559">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ extension LogOnError<T> on AsyncValue<T> {
|
||||
}
|
||||
|
||||
if (hasError && !hasValue) {
|
||||
_asyncErrorLogger.severe("$error", error, stackTrace);
|
||||
_asyncErrorLogger.severe('Could not load value', error, stackTrace);
|
||||
return onError?.call(error, stackTrace) ??
|
||||
ScaffoldErrorBody(errorMsg: error?.toString());
|
||||
}
|
||||
|
||||
5
mobile/lib/extensions/response_extensions.dart
Normal file
5
mobile/lib/extensions/response_extensions.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'package:http/http.dart';
|
||||
|
||||
extension LoggerExtension on Response {
|
||||
String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body";
|
||||
}
|
||||
@@ -73,15 +73,14 @@ Future<void> initApp() async {
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
log.severe(
|
||||
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
|
||||
details,
|
||||
'FlutterError - Catch all',
|
||||
"${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
|
||||
details.stack,
|
||||
);
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
log.severe('PlatformDispatcher - Catch all error: $error', error, stack);
|
||||
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
|
||||
log.severe('PlatformDispatcher - Catch all', error, stack);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ mixin ErrorLoggerMixin {
|
||||
/// Else, logs the error to the overrided logger and returns an AsyncError<>
|
||||
AsyncFuture<T> guardError<T>(
|
||||
Future<T> Function() fn, {
|
||||
required String errorMessage,
|
||||
Level logLevel = Level.SEVERE,
|
||||
}) async {
|
||||
try {
|
||||
final result = await fn();
|
||||
return AsyncData(result);
|
||||
} catch (error, stackTrace) {
|
||||
logger.log(logLevel, "$error", error, stackTrace);
|
||||
logger.log(logLevel, errorMessage, error, stackTrace);
|
||||
return AsyncError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
@@ -26,12 +27,13 @@ mixin ErrorLoggerMixin {
|
||||
Future<T> logError<T>(
|
||||
Future<T> Function() fn, {
|
||||
required T defaultValue,
|
||||
required String errorMessage,
|
||||
Level logLevel = Level.SEVERE,
|
||||
}) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error, stackTrace) {
|
||||
logger.log(logLevel, "$error", error, stackTrace);
|
||||
logger.log(logLevel, errorMessage, error, stackTrace);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class ActivityService with ErrorLoggerMixin {
|
||||
return list != null ? list.map(Activity.fromDto).toList() : [];
|
||||
},
|
||||
defaultValue: [],
|
||||
errorMessage: "Failed to get all activities for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ class ActivityService with ErrorLoggerMixin {
|
||||
return dto?.comments ?? 0;
|
||||
},
|
||||
defaultValue: 0,
|
||||
errorMessage: "Failed to statistics for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ class ActivityService with ErrorLoggerMixin {
|
||||
return true;
|
||||
},
|
||||
defaultValue: false,
|
||||
errorMessage: "Failed to delete activity",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,21 +57,24 @@ class ActivityService with ErrorLoggerMixin {
|
||||
String? assetId,
|
||||
String? comment,
|
||||
}) async {
|
||||
return guardError(() async {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
throw NoResponseDtoError();
|
||||
});
|
||||
return guardError(
|
||||
() async {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
throw NoResponseDtoError();
|
||||
},
|
||||
errorMessage: "Failed to create $type for album $albumId",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
|
||||
class AlbumThumbnailCard extends StatelessWidget {
|
||||
final Function()? onTap;
|
||||
@@ -45,8 +45,8 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() => ImmichImage.thumbnail(
|
||||
album.thumbnail.value,
|
||||
buildAlbumThumbnail() => ImmichThumbnail(
|
||||
asset: album.thumbnail.value,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
|
||||
class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
@@ -16,8 +16,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
ImmichImage.thumbnail(
|
||||
asset,
|
||||
ImmichThumbnail(
|
||||
asset: asset,
|
||||
width: 500,
|
||||
height: 500,
|
||||
),
|
||||
|
||||
@@ -12,7 +12,7 @@ import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
|
||||
@RoutePage()
|
||||
class SharingPage extends HookConsumerWidget {
|
||||
@@ -72,8 +72,8 @@ class SharingPage extends HookConsumerWidget {
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: ImmichImage.thumbnail(
|
||||
album.thumbnail.value,
|
||||
child: ImmichThumbnail(
|
||||
asset: album.thumbnail.value,
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
/// The local image provider for an asset
|
||||
/// Only viable
|
||||
class ImmichLocalImageProvider extends ImageProvider<Asset> {
|
||||
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||
final Asset asset;
|
||||
|
||||
ImmichLocalImageProvider({
|
||||
@@ -21,15 +21,18 @@ class ImmichLocalImageProvider extends ImageProvider<Asset> {
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<Asset> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(asset);
|
||||
Future<ImmichLocalImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) {
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichLocalImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
codec: _codec(key.asset, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
@@ -82,11 +85,6 @@ class ImmichLocalImageProvider extends ImageProvider<Asset> {
|
||||
yield codec;
|
||||
} catch (error) {
|
||||
throw StateError("Loading asset ${asset.fileName} failed");
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
// Clean up this file
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
/// The local image provider for an asset
|
||||
/// Only viable
|
||||
class ImmichLocalThumbnailProvider extends ImageProvider<Asset> {
|
||||
final Asset asset;
|
||||
final int height;
|
||||
final int width;
|
||||
|
||||
ImmichLocalThumbnailProvider({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<Asset> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(asset);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.fileName);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
Asset key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes = await asset.local?.thumbnailDataWithSize(
|
||||
const ThumbnailSize.square(32),
|
||||
quality: 75,
|
||||
);
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${asset.fileName} failed");
|
||||
}
|
||||
|
||||
final normalThumbBytes =
|
||||
await asset.local?.thumbnailDataWithSize(ThumbnailSize(width, height));
|
||||
if (normalThumbBytes == null) {
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${asset.fileName} failed",
|
||||
);
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
|
||||
chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ImmichLocalThumbnailProvider) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return asset == other.asset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.hashCode;
|
||||
}
|
||||
@@ -13,10 +13,13 @@ import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
/// Our Image Provider HTTP client to make the request
|
||||
final _httpClient = HttpClient()..autoUncompress = false;
|
||||
final _httpClient = HttpClient()
|
||||
..autoUncompress = false
|
||||
..maxConnectionsPerHost = 10;
|
||||
|
||||
/// The remote image provider
|
||||
class ImmichRemoteImageProvider extends ImageProvider<String> {
|
||||
class ImmichRemoteImageProvider
|
||||
extends ImageProvider<ImmichRemoteImageProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
@@ -32,16 +35,20 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<String> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture('$assetId,$isThumbnail');
|
||||
Future<ImmichRemoteImageProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) {
|
||||
final id = key.split(',').first;
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichRemoteImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(id, decode, chunkEvents),
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
);
|
||||
@@ -61,14 +68,14 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
String key,
|
||||
ImmichRemoteImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a preview to the chunk events
|
||||
if (_loadPreview || isThumbnail) {
|
||||
if (_loadPreview || key.isThumbnail) {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
assetId,
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
|
||||
@@ -80,14 +87,14 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
|
||||
}
|
||||
|
||||
// Guard thumnbail rendering
|
||||
if (isThumbnail) {
|
||||
if (key.isThumbnail) {
|
||||
await chunkEvents.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the higher resolution version of the image
|
||||
final url = getThumbnailUrlForRemoteId(
|
||||
assetId,
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
|
||||
@@ -96,7 +103,7 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
|
||||
// Load the final remote image
|
||||
if (_useOriginal) {
|
||||
// Load the original image
|
||||
final url = getImageUrlFromId(assetId);
|
||||
final url = getImageUrlFromId(key.assetId);
|
||||
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
|
||||
yield codec;
|
||||
}
|
||||
@@ -137,7 +144,7 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ImmichRemoteImageProvider) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return assetId == other.assetId;
|
||||
return assetId == other.assetId && isThumbnail == other.isThumbnail;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -12,14 +12,17 @@ import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
/// Our HTTP client to make the request
|
||||
final _httpClient = HttpClient()
|
||||
..autoUncompress = false
|
||||
..maxConnectionsPerHost = 100;
|
||||
|
||||
/// The remote image provider
|
||||
class ImmichRemoteThumbnailProvider extends ImageProvider<String> {
|
||||
class ImmichRemoteThumbnailProvider
|
||||
extends ImageProvider<ImmichRemoteThumbnailProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
/// Our HTTP client to make the request
|
||||
final _httpClient = HttpClient()..autoUncompress = false;
|
||||
|
||||
ImmichRemoteThumbnailProvider({
|
||||
required this.assetId,
|
||||
});
|
||||
@@ -27,12 +30,17 @@ class ImmichRemoteThumbnailProvider extends ImageProvider<String> {
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<String> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(assetId);
|
||||
Future<ImmichRemoteThumbnailProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) {
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichRemoteThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
@@ -43,13 +51,13 @@ class ImmichRemoteThumbnailProvider extends ImageProvider<String> {
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
String key,
|
||||
ImmichRemoteThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a preview to the chunk events
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
assetId,
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
@@ -39,7 +40,8 @@ class ImageViewerService {
|
||||
final failedResponse =
|
||||
imageResponse.statusCode != 200 ? imageResponse : motionReponse;
|
||||
_log.severe(
|
||||
"Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}",
|
||||
"Motion asset download failed",
|
||||
failedResponse.toLoggerString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -75,9 +77,7 @@ class ImageViewerService {
|
||||
.downloadFileWithHttpInfo(asset.remoteId!);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe(
|
||||
"Asset download failed with status - ${res.statusCode} and response - ${res.body}",
|
||||
);
|
||||
_log.severe("Asset download failed", res.toLoggerString());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ class ImageViewerService {
|
||||
return entity != null;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error saving file ${error.toString()}", error, stack);
|
||||
_log.severe("Error saving downloaded asset", error, stack);
|
||||
return false;
|
||||
} finally {
|
||||
// Clear temp files
|
||||
|
||||
@@ -48,7 +48,7 @@ class DescriptionInput extends HookConsumerWidget {
|
||||
);
|
||||
} catch (error, stack) {
|
||||
hasError.value = true;
|
||||
_log.severe("Error updating description $error", error, stack);
|
||||
_log.severe("Error updating description", error, stack);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "description_input_submit_error".tr(),
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class VideoPlayerControls extends ConsumerStatefulWidget {
|
||||
@@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
|
||||
children: [
|
||||
if (_displayBufferingIndicator)
|
||||
const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildHitArea(),
|
||||
@@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
|
||||
@override
|
||||
void dispose() {
|
||||
_dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
|
||||
final oldController = _chewieController;
|
||||
_chewieController = ChewieController.of(context);
|
||||
controller = chewieController.videoPlayerController;
|
||||
_latestValue = controller.value;
|
||||
|
||||
if (oldController != chewieController) {
|
||||
_dispose();
|
||||
@@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (_latestValue.isPlaying) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
} else {
|
||||
if (!_latestValue.isPlaying) {
|
||||
_playPause();
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
},
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
@@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
|
||||
|
||||
controller.addListener(_updateState);
|
||||
_latestValue = controller.value;
|
||||
controller.addListener(_updateState);
|
||||
|
||||
if (controller.value.isPlaying || chewieController.autoPlay) {
|
||||
_startHideTimer();
|
||||
@@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
|
||||
}
|
||||
|
||||
void _startHideTimer() {
|
||||
final hideControlsTimer = chewieController.hideControlsTimer.isNegative
|
||||
? ChewieController.defaultHideControlsTimer
|
||||
: chewieController.hideControlsTimer;
|
||||
final hideControlsTimer = chewieController.hideControlsTimer;
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = Timer(hideControlsTimer, () {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
@@ -10,6 +11,7 @@ import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
@@ -26,13 +28,13 @@ import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.da
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.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/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
||||
@@ -131,7 +133,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
void toggleFavorite(Asset asset) =>
|
||||
ref.read(assetProvider.notifier).toggleFavorite([asset]);
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
Future<void> precacheNextImage(int index) async {
|
||||
void onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
debugPrint('Error precaching next image: $exception, $stackTrace');
|
||||
@@ -139,7 +141,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
||||
if (index < totalAssets && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
precacheImage(
|
||||
await precacheImage(
|
||||
ImmichImage.imageProvider(asset: asset),
|
||||
context,
|
||||
onError: onError,
|
||||
@@ -481,15 +483,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CachedNetworkImage(
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
imageUrl:
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId',
|
||||
httpHeaders: {
|
||||
"x-immich-user-token": Store.get(StoreKey.accessToken),
|
||||
},
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
image: ImmichRemoteImageProvider(assetId: assetId!),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -704,6 +700,33 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (ref.read(showControlsProvider)) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// No need to await this
|
||||
unawaited(
|
||||
// Delay this a bit so we can finish loading the page
|
||||
Future.delayed(const Duration(milliseconds: 400)).then(
|
||||
// Precache the next image
|
||||
(_) => precacheNextImage(currentIndex.value + 1),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
ref.listen(showControlsProvider, (_, show) {
|
||||
if (show) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
@@ -728,9 +751,22 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||
},
|
||||
loadingBuilder: (context, event, index) => ImmichImage.thumbnail(
|
||||
asset(),
|
||||
fit: BoxFit.contain,
|
||||
loadingBuilder: (context, event, index) => ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
),
|
||||
),
|
||||
ImmichThumbnail(
|
||||
asset: asset(),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
pageController: controller,
|
||||
scrollPhysics: isZoomed.value
|
||||
@@ -741,12 +777,16 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
),
|
||||
itemCount: totalAssets,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
onPageChanged: (value) async {
|
||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||
precacheNextImage(next);
|
||||
HapticFeedback.selectionClick();
|
||||
currentIndex.value = value;
|
||||
stackIndex.value = -1;
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
// Wait for page change animation to finish
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
// Then precache the next image
|
||||
unawaited(precacheNextImage(next));
|
||||
},
|
||||
builder: (context, index) {
|
||||
final a =
|
||||
@@ -794,7 +834,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: VideoViewerPage(
|
||||
onPlaying: () => isPlayingVideo.value = true,
|
||||
onPlaying: () {
|
||||
isPlayingVideo.value = true;
|
||||
},
|
||||
onPaused: () =>
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => isPlayingVideo.value = false,
|
||||
@@ -803,7 +845,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
placeholder: Image(
|
||||
image: provider,
|
||||
fit: BoxFit.fitWidth,
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||
|
||||
@RoutePage()
|
||||
// ignore: must_be_immutable
|
||||
class VideoViewerPage extends HookConsumerWidget {
|
||||
class VideoViewerPage extends HookWidget {
|
||||
final Asset asset;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
@@ -42,211 +34,53 @@ class VideoViewerPage extends HookConsumerWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (asset.isLocal && asset.livePhotoVideoId == null) {
|
||||
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: videoFile.when(
|
||||
data: (data) => VideoPlayer(
|
||||
file: data,
|
||||
isMotionVideo: false,
|
||||
onVideoEnded: () {},
|
||||
),
|
||||
error: (error, stackTrace) => Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
loading: () => showDownloadingIndicator
|
||||
? const Center(child: ImmichLoadingIndicator())
|
||||
: Container(),
|
||||
),
|
||||
);
|
||||
}
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final String videoUrl = isMotionVideo
|
||||
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
|
||||
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
VideoPlayer(
|
||||
url: videoUrl,
|
||||
accessToken: Store.get(StoreKey.accessToken),
|
||||
isMotionVideo: isMotionVideo,
|
||||
onVideoEnded: onVideoEnded,
|
||||
onPaused: onPaused,
|
||||
onPlaying: onPlaying,
|
||||
placeholder: placeholder,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
showControls: showControls,
|
||||
showDownloadingIndicator: showDownloadingIndicator,
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
opacity: (downloadAssetStatus == DownloadAssetStatus.loading &&
|
||||
showDownloadingIndicator)
|
||||
? 1.0
|
||||
: 0.0,
|
||||
child: SizedBox(
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
child: const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _fileFamily =
|
||||
FutureProvider.family<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(
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useChewieController(
|
||||
asset,
|
||||
controlsSafeAreaMinimum: const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
showOptions: true,
|
||||
showControlsOnInitialize: false,
|
||||
videoPlayerController: videoPlayerController,
|
||||
autoPlay: true,
|
||||
autoInitialize: true,
|
||||
allowFullScreen: false,
|
||||
allowedScreenSleep: false,
|
||||
showControls: widget.showControls && !widget.isMotionVideo,
|
||||
placeholder: placeholder,
|
||||
showControls: showControls && !isMotionVideo,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
customControls: const VideoPlayerControls(),
|
||||
hideControlsTimer: widget.hideControlsTimer,
|
||||
onPlaying: onPlaying,
|
||||
onPaused: onPaused,
|
||||
onVideoEnded: onVideoEnded,
|
||||
);
|
||||
|
||||
// Loading
|
||||
return PopScope(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (controller == null) {
|
||||
return Stack(
|
||||
children: [
|
||||
if (placeholder != null) placeholder!,
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
child: 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
} catch (e, stack) {
|
||||
log.severe(
|
||||
"Failed to get thumbnail for album ${album.name}",
|
||||
e.toString(),
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
import 'package:immich_mobile/utils/storage_indicator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
@@ -134,10 +134,10 @@ class ThumbnailImage extends StatelessWidget {
|
||||
tag: isFromDto
|
||||
? '${asset.remoteId}-$heroOffset'
|
||||
: asset.id + heroOffset,
|
||||
child: ImmichImage.thumbnail(
|
||||
asset,
|
||||
height: 300,
|
||||
width: 300,
|
||||
child: ImmichThumbnail(
|
||||
asset: asset,
|
||||
height: 250,
|
||||
width: 250,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -108,7 +108,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
.then((_) => log.info("Logout was successful for $userEmail"))
|
||||
.onError(
|
||||
(error, stackTrace) =>
|
||||
log.severe("Error logging out $userEmail", error, stackTrace),
|
||||
log.severe("Logout failed for $userEmail", error, stackTrace),
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
@@ -129,8 +129,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
);
|
||||
} catch (e) {
|
||||
log.severe("Error logging out $e");
|
||||
} catch (e, stack) {
|
||||
log.severe('Logout failed', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class OAuthService {
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log.severe("Error performing oAuthLogin: ${e.toString()}", e, stack);
|
||||
log.severe("OAuth login failed", e, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
@@ -51,7 +52,8 @@ class MapStateNotifier extends _$MapStateNotifier {
|
||||
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
|
||||
);
|
||||
_log.severe(
|
||||
"Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}",
|
||||
"Cannot fetch map light style",
|
||||
lightResponse.toLoggerString(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -77,9 +79,7 @@ class MapStateNotifier extends _$MapStateNotifier {
|
||||
state = state.copyWith(
|
||||
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
|
||||
);
|
||||
_log.severe(
|
||||
"Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}",
|
||||
);
|
||||
_log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class MapSerivce with ErrorLoggerMixin {
|
||||
return markers?.map(MapMarker.fromDto) ?? [];
|
||||
},
|
||||
defaultValue: [],
|
||||
errorMessage: "Failed to get map markers",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,8 @@ class MapUtils {
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
return (currentUserLocation, null);
|
||||
} catch (error) {
|
||||
_log.severe(
|
||||
"Cannot get user's current location due to ${error.toString()}",
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot get user's current location", error, stack);
|
||||
return (null, LocationPermission.unableToDetermine);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ class MapAssetGrid extends HookConsumerWidget {
|
||||
},
|
||||
error: (error, stackTrace) {
|
||||
log.warning(
|
||||
"Cannot get assets in the current map bounds $error",
|
||||
"Cannot get assets in the current map bounds",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ class MemoryService {
|
||||
|
||||
return memories.isNotEmpty ? memories : null;
|
||||
} catch (error, stack) {
|
||||
log.severe("Cannot get memories ${error.toString()}", error, stack);
|
||||
log.severe("Cannot get memories", error, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
|
||||
class MemoryCard extends StatelessWidget {
|
||||
@@ -21,8 +22,6 @@ class MemoryCard extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
String get accessToken => Store.get(StoreKey.accessToken);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
@@ -37,27 +36,15 @@ class MemoryCard extends StatelessWidget {
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Stack(
|
||||
children: [
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
isThumbnail: true,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(color: Colors.black.withOpacity(0.2)),
|
||||
),
|
||||
SizedBox.expand(
|
||||
child: _BlurredBackdrop(asset: asset),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Determine the fit using the aspect ratio
|
||||
BoxFit fit = BoxFit.fitWidth;
|
||||
BoxFit fit = BoxFit.contain;
|
||||
if (asset.width != null && asset.height != null) {
|
||||
final aspectRatio = asset.height! / asset.width!;
|
||||
final aspectRatio = asset.width! / asset.height!;
|
||||
final phoneAspectRatio =
|
||||
constraints.maxWidth / constraints.maxHeight;
|
||||
// Look for a 25% difference in either direction
|
||||
@@ -113,3 +100,50 @@ class MemoryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlurredBackdrop extends HookWidget {
|
||||
final Asset asset;
|
||||
|
||||
const _BlurredBackdrop({required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blurhash = useBlurHashRef(asset).value;
|
||||
if (blurhash != null) {
|
||||
// Use a nice cheap blur hash image decoration
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: MemoryImage(
|
||||
blurhash,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Fall back to using a more expensive image filtered
|
||||
// Since the ImmichImage is already precached, we can
|
||||
// safely use that as the image provider
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,25 +109,13 @@ class MemoryPage extends HookConsumerWidget {
|
||||
asset = memories[nextMemoryIndex].assets.first;
|
||||
}
|
||||
|
||||
// Gets the thumbnail url and precaches it
|
||||
final precaches = <Future<dynamic>>[];
|
||||
|
||||
precaches.addAll([
|
||||
precacheImage(
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
),
|
||||
context,
|
||||
// Precache the asset
|
||||
await precacheImage(
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
),
|
||||
precacheImage(
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
isThumbnail: true,
|
||||
),
|
||||
context,
|
||||
),
|
||||
]);
|
||||
await Future.wait(precaches);
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// Precache the next page right away if we are on the first page
|
||||
@@ -136,11 +124,14 @@ class MemoryPage extends HookConsumerWidget {
|
||||
.then((_) => precacheAsset(1));
|
||||
}
|
||||
|
||||
onAssetChanged(int otherIndex) {
|
||||
Future<void> onAssetChanged(int otherIndex) async {
|
||||
HapticFeedback.selectionClick();
|
||||
currentAssetPage.value = otherIndex;
|
||||
precacheAsset(otherIndex + 1);
|
||||
updateProgressText();
|
||||
// Wait for page change animation to finish
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
// And then precache the next asset
|
||||
await precacheAsset(otherIndex + 1);
|
||||
}
|
||||
|
||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||
|
||||
@@ -40,7 +40,7 @@ class PartnerService {
|
||||
return userDtos.map((u) => User.fromPartnerDto(u)).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning("failed to get partners for direction $direction:\n$e");
|
||||
_log.warning("Failed to get partners for direction $direction", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class PartnerService {
|
||||
partner.isPartnerSharedBy = false;
|
||||
await _db.writeTxn(() => _db.users.put(partner));
|
||||
} catch (e) {
|
||||
_log.warning("failed to remove partner ${partner.id}:\n$e");
|
||||
_log.warning("Failed to remove partner ${partner.id}", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -66,7 +66,7 @@ class PartnerService {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning("failed to add partner ${partner.id}:\n$e");
|
||||
_log.warning("Failed to add partner ${partner.id}", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class PartnerService {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning("failed to update partner ${partner.id}:\n$e");
|
||||
_log.warning("Failed to update partner ${partner.id}", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class SharedLinkService {
|
||||
? AsyncData(list.map(SharedLink.fromDto).toList())
|
||||
: const AsyncData([]);
|
||||
} catch (e, stack) {
|
||||
_log.severe("failed to fetch shared links - $e");
|
||||
_log.severe("Failed to fetch shared links", e, stack);
|
||||
return AsyncError(e, stack);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class SharedLinkService {
|
||||
try {
|
||||
return await _apiService.sharedLinkApi.removeSharedLink(id);
|
||||
} catch (e) {
|
||||
_log.severe("failed to delete shared link id - $id with error - $e");
|
||||
_log.severe("Failed to delete shared link id - $id", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class SharedLinkService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("failed to create shared link with error - $e");
|
||||
_log.severe("Failed to create shared link", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ class SharedLinkService {
|
||||
return SharedLink.fromDto(responseDto);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("failed to update shared link id - $id with error - $e");
|
||||
_log.severe("Failed to update shared link id - $id", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class TrashNotifier extends StateNotifier<bool> {
|
||||
.read(syncServiceProvider)
|
||||
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
|
||||
_log.severe("Cannot empty trash", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class TrashNotifier extends StateNotifier<bool> {
|
||||
|
||||
return isRemoved;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
|
||||
_log.severe("Cannot remove assets", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class TrashNotifier extends StateNotifier<bool> {
|
||||
return true;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
_log.severe("Cannot restore assets", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ class TrashNotifier extends StateNotifier<bool> {
|
||||
await _db.assets.putAll(updatedAssets);
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
_log.severe("Cannot restore trash", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class TrashService {
|
||||
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds));
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore assets ${error.toString()}", error, stack);
|
||||
_log.severe("Cannot restore assets", error, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class TrashService {
|
||||
try {
|
||||
await _apiService.trashApi.emptyTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
|
||||
_log.severe("Cannot empty trash", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class TrashService {
|
||||
try {
|
||||
await _apiService.trashApi.restoreTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
_log.severe("Cannot restore trash", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -16,28 +16,31 @@ class AuthGuard extends AutoRouteGuard {
|
||||
resolver.next(true);
|
||||
|
||||
try {
|
||||
var res = await _apiService.authenticationApi.validateAccessToken();
|
||||
// Look in the store for an access token
|
||||
Store.get(StoreKey.accessToken);
|
||||
|
||||
// Validate the access token with the server
|
||||
final res = await _apiService.authenticationApi.validateAccessToken();
|
||||
if (res == null || res.authStatus != true) {
|
||||
// If the access token is invalid, take user back to login
|
||||
_log.fine("User token is invalid. Redirecting to login");
|
||||
_log.fine('User token is invalid. Redirecting to login');
|
||||
router.replaceAll([const LoginRoute()]);
|
||||
}
|
||||
} on StoreKeyNotFoundException catch (_) {
|
||||
// If there is no access token, take us to the login page
|
||||
_log.warning('No access token in the store.');
|
||||
router.replaceAll([const LoginRoute()]);
|
||||
return;
|
||||
} on ApiException catch (e) {
|
||||
if (e.code == HttpStatus.badRequest &&
|
||||
e.innerException is SocketException) {
|
||||
// offline?
|
||||
_log.fine(
|
||||
"Unable to validate user token. User may be offline and offline browsing is allowed.",
|
||||
);
|
||||
} else {
|
||||
debugPrint("Error [onNavigation] ${e.toString()}");
|
||||
// On an unauthorized request, take us to the login page
|
||||
if (e.code == HttpStatus.unauthorized) {
|
||||
_log.warning("Unauthorized access token.");
|
||||
router.replaceAll([const LoginRoute()]);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [onNavigation] ${e.toString()}");
|
||||
router.replaceAll([const LoginRoute()]);
|
||||
return;
|
||||
// Otherwise, this is not fatal, but we still log the warning
|
||||
_log.warning('Error validating access token from server: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||
void Function()? onPaused,
|
||||
Widget? placeholder,
|
||||
bool showControls = true,
|
||||
Duration hideControlsTimer = const Duration(seconds: 5),
|
||||
Duration hideControlsTimer = const Duration(milliseconds: 1500),
|
||||
bool showDownloadingIndicator = true,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
@@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
|
||||
this.onPaused,
|
||||
this.placeholder,
|
||||
this.showControls = true,
|
||||
this.hideControlsTimer = const Duration(seconds: 5),
|
||||
this.hideControlsTimer = const Duration(milliseconds: 1500),
|
||||
this.showDownloadingIndicator = true,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ class Asset {
|
||||
// stack handling to properly handle it
|
||||
stackParentId =
|
||||
remote.stackParentId == remote.id ? null : remote.stackParentId,
|
||||
stackCount = remote.stackCount;
|
||||
stackCount = remote.stackCount,
|
||||
thumbhash = remote.thumbhash;
|
||||
|
||||
Asset.local(AssetEntity local, List<int> hash)
|
||||
: localId = local.id,
|
||||
@@ -91,6 +92,7 @@ class Asset {
|
||||
this.stackCount = 0,
|
||||
this.isReadOnly = false,
|
||||
this.isOffline = false,
|
||||
this.thumbhash,
|
||||
});
|
||||
|
||||
@ignore
|
||||
@@ -119,6 +121,8 @@ class Asset {
|
||||
/// because Isar cannot sort lists of byte arrays
|
||||
String checksum;
|
||||
|
||||
String? thumbhash;
|
||||
|
||||
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||
String? remoteId;
|
||||
|
||||
@@ -171,6 +175,11 @@ class Asset {
|
||||
|
||||
int? stackCount;
|
||||
|
||||
/// Aspect ratio of the asset
|
||||
@ignore
|
||||
double? get aspectRatio =>
|
||||
width == null || height == null ? 0 : width! / height!;
|
||||
|
||||
/// `true` if this [Asset] is present on the device
|
||||
@ignore
|
||||
bool get isLocal => localId != null;
|
||||
@@ -274,6 +283,7 @@ class Asset {
|
||||
a.exifInfo?.latitude != exifInfo?.latitude ||
|
||||
a.exifInfo?.longitude != exifInfo?.longitude ||
|
||||
// no local stack count or different count from remote
|
||||
a.thumbhash != thumbhash ||
|
||||
((stackCount == null && a.stackCount != null) ||
|
||||
(stackCount != null &&
|
||||
a.stackCount != null &&
|
||||
@@ -338,6 +348,7 @@ class Asset {
|
||||
isReadOnly: a.isReadOnly,
|
||||
isOffline: a.isOffline,
|
||||
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
|
||||
thumbhash: a.thumbhash,
|
||||
);
|
||||
} else {
|
||||
// add only missing values (and set isLocal to true)
|
||||
@@ -374,6 +385,7 @@ class Asset {
|
||||
ExifInfo? exifInfo,
|
||||
String? stackParentId,
|
||||
int? stackCount,
|
||||
String? thumbhash,
|
||||
}) =>
|
||||
Asset(
|
||||
id: id ?? this.id,
|
||||
@@ -398,6 +410,7 @@ class Asset {
|
||||
exifInfo: exifInfo ?? this.exifInfo,
|
||||
stackParentId: stackParentId ?? this.stackParentId,
|
||||
stackCount: stackCount ?? this.stackCount,
|
||||
thumbhash: thumbhash ?? this.thumbhash,
|
||||
);
|
||||
|
||||
Future<void> put(Isar db) async {
|
||||
|
||||
220
mobile/lib/shared/models/asset.g.dart
generated
220
mobile/lib/shared/models/asset.g.dart
generated
@@ -102,19 +102,24 @@ const AssetSchema = CollectionSchema(
|
||||
name: r'stackParentId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'type': PropertySchema(
|
||||
r'thumbhash': PropertySchema(
|
||||
id: 17,
|
||||
name: r'thumbhash',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'type': PropertySchema(
|
||||
id: 18,
|
||||
name: r'type',
|
||||
type: IsarType.byte,
|
||||
enumMap: _AssettypeEnumValueMap,
|
||||
),
|
||||
r'updatedAt': PropertySchema(
|
||||
id: 18,
|
||||
id: 19,
|
||||
name: r'updatedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'width': PropertySchema(
|
||||
id: 19,
|
||||
id: 20,
|
||||
name: r'width',
|
||||
type: IsarType.int,
|
||||
)
|
||||
@@ -210,6 +215,12 @@ int _assetEstimateSize(
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
{
|
||||
final value = object.thumbhash;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
return bytesCount;
|
||||
}
|
||||
|
||||
@@ -236,9 +247,10 @@ void _assetSerialize(
|
||||
writer.writeString(offsets[14], object.remoteId);
|
||||
writer.writeLong(offsets[15], object.stackCount);
|
||||
writer.writeString(offsets[16], object.stackParentId);
|
||||
writer.writeByte(offsets[17], object.type.index);
|
||||
writer.writeDateTime(offsets[18], object.updatedAt);
|
||||
writer.writeInt(offsets[19], object.width);
|
||||
writer.writeString(offsets[17], object.thumbhash);
|
||||
writer.writeByte(offsets[18], object.type.index);
|
||||
writer.writeDateTime(offsets[19], object.updatedAt);
|
||||
writer.writeInt(offsets[20], object.width);
|
||||
}
|
||||
|
||||
Asset _assetDeserialize(
|
||||
@@ -266,10 +278,11 @@ Asset _assetDeserialize(
|
||||
remoteId: reader.readStringOrNull(offsets[14]),
|
||||
stackCount: reader.readLongOrNull(offsets[15]),
|
||||
stackParentId: reader.readStringOrNull(offsets[16]),
|
||||
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ??
|
||||
thumbhash: reader.readStringOrNull(offsets[17]),
|
||||
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
|
||||
AssetType.other,
|
||||
updatedAt: reader.readDateTime(offsets[18]),
|
||||
width: reader.readIntOrNull(offsets[19]),
|
||||
updatedAt: reader.readDateTime(offsets[19]),
|
||||
width: reader.readIntOrNull(offsets[20]),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
@@ -316,11 +329,13 @@ P _assetDeserializeProp<P>(
|
||||
case 16:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 17:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 18:
|
||||
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
|
||||
AssetType.other) as P;
|
||||
case 18:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 19:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 20:
|
||||
return (reader.readIntOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
@@ -2078,6 +2093,152 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'thumbhash',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'thumbhash',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEqualTo(
|
||||
String? value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'thumbhash',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashGreaterThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'thumbhash',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashLessThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'thumbhash',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashBetween(
|
||||
String? lower,
|
||||
String? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'thumbhash',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'thumbhash',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'thumbhash',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashContains(
|
||||
String value,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'thumbhash',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashMatches(
|
||||
String pattern,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'thumbhash',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'thumbhash',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
property: r'thumbhash',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
|
||||
AssetType value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
@@ -2462,6 +2623,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhash() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'thumbhash', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhashDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'thumbhash', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'type', Sort.asc);
|
||||
@@ -2716,6 +2889,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhash() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'thumbhash', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhashDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'thumbhash', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'type', Sort.asc);
|
||||
@@ -2864,6 +3049,13 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByThumbhash(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'thumbhash', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'type');
|
||||
@@ -2992,6 +3184,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, String?, QQueryOperations> thumbhashProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'thumbhash');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'type');
|
||||
|
||||
@@ -9,6 +9,7 @@ part 'logger_message.model.g.dart';
|
||||
class LoggerMessage {
|
||||
Id id = Isar.autoIncrement;
|
||||
String message;
|
||||
String? details;
|
||||
@Enumerated(EnumType.ordinal)
|
||||
LogLevel level = LogLevel.INFO;
|
||||
DateTime createdAt;
|
||||
@@ -17,6 +18,7 @@ class LoggerMessage {
|
||||
|
||||
LoggerMessage({
|
||||
required this.message,
|
||||
required this.details,
|
||||
required this.level,
|
||||
required this.createdAt,
|
||||
required this.context1,
|
||||
|
||||
220
mobile/lib/shared/models/logger_message.model.g.dart
generated
220
mobile/lib/shared/models/logger_message.model.g.dart
generated
@@ -32,14 +32,19 @@ const LoggerMessageSchema = CollectionSchema(
|
||||
name: r'createdAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'level': PropertySchema(
|
||||
r'details': PropertySchema(
|
||||
id: 3,
|
||||
name: r'details',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'level': PropertySchema(
|
||||
id: 4,
|
||||
name: r'level',
|
||||
type: IsarType.byte,
|
||||
enumMap: _LoggerMessagelevelEnumValueMap,
|
||||
),
|
||||
r'message': PropertySchema(
|
||||
id: 4,
|
||||
id: 5,
|
||||
name: r'message',
|
||||
type: IsarType.string,
|
||||
)
|
||||
@@ -76,6 +81,12 @@ int _loggerMessageEstimateSize(
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
{
|
||||
final value = object.details;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
bytesCount += 3 + object.message.length * 3;
|
||||
return bytesCount;
|
||||
}
|
||||
@@ -89,8 +100,9 @@ void _loggerMessageSerialize(
|
||||
writer.writeString(offsets[0], object.context1);
|
||||
writer.writeString(offsets[1], object.context2);
|
||||
writer.writeDateTime(offsets[2], object.createdAt);
|
||||
writer.writeByte(offsets[3], object.level.index);
|
||||
writer.writeString(offsets[4], object.message);
|
||||
writer.writeString(offsets[3], object.details);
|
||||
writer.writeByte(offsets[4], object.level.index);
|
||||
writer.writeString(offsets[5], object.message);
|
||||
}
|
||||
|
||||
LoggerMessage _loggerMessageDeserialize(
|
||||
@@ -103,9 +115,10 @@ LoggerMessage _loggerMessageDeserialize(
|
||||
context1: reader.readStringOrNull(offsets[0]),
|
||||
context2: reader.readStringOrNull(offsets[1]),
|
||||
createdAt: reader.readDateTime(offsets[2]),
|
||||
level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[3])] ??
|
||||
details: reader.readStringOrNull(offsets[3]),
|
||||
level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ??
|
||||
LogLevel.ALL,
|
||||
message: reader.readString(offsets[4]),
|
||||
message: reader.readString(offsets[5]),
|
||||
);
|
||||
object.id = id;
|
||||
return object;
|
||||
@@ -125,9 +138,11 @@ P _loggerMessageDeserializeProp<P>(
|
||||
case 2:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 4:
|
||||
return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ??
|
||||
LogLevel.ALL) as P;
|
||||
case 4:
|
||||
case 5:
|
||||
return (reader.readString(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
@@ -619,6 +634,160 @@ extension LoggerMessageQueryFilter
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'details',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'details',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsEqualTo(
|
||||
String? value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'details',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsGreaterThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'details',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsLessThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'details',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsBetween(
|
||||
String? lower,
|
||||
String? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'details',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'details',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'details',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'details',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsMatches(String pattern, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'details',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'details',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
|
||||
detailsIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
property: r'details',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition> idEqualTo(
|
||||
Id value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
@@ -913,6 +1082,18 @@ extension LoggerMessageQuerySortBy
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByDetails() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'details', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByDetailsDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'details', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByLevel() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'level', Sort.asc);
|
||||
@@ -979,6 +1160,18 @@ extension LoggerMessageQuerySortThenBy
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenByDetails() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'details', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenByDetailsDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'details', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
@@ -1038,6 +1231,13 @@ extension LoggerMessageQueryWhereDistinct
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QDistinct> distinctByDetails(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'details', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LoggerMessage, QDistinct> distinctByLevel() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'level');
|
||||
@@ -1078,6 +1278,12 @@ extension LoggerMessageQueryProperty
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, String?, QQueryOperations> detailsProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'details');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<LoggerMessage, LogLevel, QQueryOperations> levelProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'level');
|
||||
|
||||
@@ -90,7 +90,7 @@ class AssetService {
|
||||
return allAssets;
|
||||
} catch (error, stack) {
|
||||
log.severe(
|
||||
'Error while getting remote assets: ${error.toString()}',
|
||||
'Error while getting remote assets',
|
||||
error,
|
||||
stack,
|
||||
);
|
||||
@@ -117,7 +117,7 @@ class AssetService {
|
||||
);
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error deleteAssets ${error.toString()}", error, stack);
|
||||
log.severe("Error while deleting assets", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
|
||||
/// The logs are written to the database and onto console, using `debugPrint` method.
|
||||
///
|
||||
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
|
||||
/// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
|
||||
/// in the class.
|
||||
///
|
||||
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
|
||||
@@ -58,6 +58,7 @@ class ImmichLogger {
|
||||
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
|
||||
final lm = LoggerMessage(
|
||||
message: record.message,
|
||||
details: record.error?.toString(),
|
||||
level: record.level.toLogLevel(),
|
||||
createdAt: record.time,
|
||||
context1: record.loggerName,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -41,7 +42,8 @@ class ShareService {
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe(
|
||||
"Asset download failed with status - ${res.statusCode} and response - ${res.body}",
|
||||
"Asset download for ${asset.fileName} failed",
|
||||
res.toLoggerString(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -68,7 +70,7 @@ class ShareService {
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
_log.severe("Share failed with error $error");
|
||||
_log.severe("Share failed", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ class SyncService {
|
||||
try {
|
||||
await _db.writeTxn(() => a.put(_db));
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to put new asset into db: $e");
|
||||
_log.severe("Failed to put new asset into db", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -173,7 +173,7 @@ class SyncService {
|
||||
}
|
||||
return false;
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to sync remote assets to db: $e");
|
||||
_log.severe("Failed to sync remote assets to db", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -232,7 +232,7 @@ class SyncService {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
||||
await upsertAssetsWithExif(toAdd + toUpdate);
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to sync remote assets to db: $e");
|
||||
_log.severe("Failed to sync remote assets to db", e);
|
||||
}
|
||||
await _updateUserAssetsETag(user, now);
|
||||
return true;
|
||||
@@ -364,7 +364,7 @@ class SyncService {
|
||||
});
|
||||
_log.info("Synced changes of remote album ${album.name} to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to sync remote album to database $e");
|
||||
_log.severe("Failed to sync remote album to database", e);
|
||||
}
|
||||
|
||||
if (album.shared || dto.shared) {
|
||||
@@ -441,7 +441,7 @@ class SyncService {
|
||||
assert(ok);
|
||||
_log.info("Removed local album $album from DB");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to remove local album $album from DB");
|
||||
_log.severe("Failed to remove local album $album from DB", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ class SyncService {
|
||||
});
|
||||
_log.info("Synced changes of local album ${ape.name} to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to update synced album ${ape.name} in DB: $e");
|
||||
_log.severe("Failed to update synced album ${ape.name} in DB", e);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -623,7 +623,7 @@ class SyncService {
|
||||
});
|
||||
_log.info("Fast synced local album ${ape.name} to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to fast sync local album ${ape.name} to DB: $e");
|
||||
_log.severe("Failed to fast sync local album ${ape.name} to DB", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -656,7 +656,7 @@ class SyncService {
|
||||
await _db.writeTxn(() => _db.albums.store(a));
|
||||
_log.info("Added a new local album to DB: ${ape.name}");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to add new local album ${ape.name} to DB: $e");
|
||||
_log.severe("Failed to add new local album ${ape.name} to DB", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,9 +706,7 @@ class SyncService {
|
||||
});
|
||||
_log.info("Upserted ${assets.length} assets into the DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe(
|
||||
"Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
|
||||
);
|
||||
_log.severe("Failed to upsert ${assets.length} assets into the DB", e);
|
||||
// give details on the errors
|
||||
assets.sort(Asset.compareByOwnerChecksum);
|
||||
final inDb = await _db.assets.getAllByOwnerIdChecksum(
|
||||
@@ -776,7 +774,7 @@ class SyncService {
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.severe("Failed to remove all local albums and assets: $e");
|
||||
_log.severe("Failed to remove all local albums and assets", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class UserService {
|
||||
final dto = await _apiService.userApi.getAllUsers(isAll);
|
||||
return dto?.map(User.fromUserDto).toList();
|
||||
} catch (e) {
|
||||
_log.warning("Failed get all users:\n$e");
|
||||
_log.warning("Failed get all users", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class UserService {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.warning("Failed to upload profile image:\n$e");
|
||||
_log.warning("Failed to upload profile image", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
43
mobile/lib/shared/ui/delayed_loading_indicator.dart
Normal file
43
mobile/lib/shared/ui/delayed_loading_indicator.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
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 FutureBuilder(
|
||||
future: Future.delayed(delay),
|
||||
builder: (context, snapshot) {
|
||||
late Widget c;
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
c = child ??
|
||||
const ImmichLoadingIndicator(
|
||||
key: ValueKey('loading'),
|
||||
);
|
||||
} else {
|
||||
c = Container(key: const ValueKey('hiding'));
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: fadeInDuration ?? Duration.zero,
|
||||
child: c,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
35
mobile/lib/shared/ui/fade_in_placeholder_image.dart
Normal file
35
mobile/lib/shared/ui/fade_in_placeholder_image.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/ui/transparent_image.dart';
|
||||
|
||||
class FadeInPlaceholderImage extends StatelessWidget {
|
||||
final Widget placeholder;
|
||||
final ImageProvider image;
|
||||
final Duration duration;
|
||||
final BoxFit fit;
|
||||
|
||||
const FadeInPlaceholderImage({
|
||||
super.key,
|
||||
required this.placeholder,
|
||||
required this.image,
|
||||
this.duration = const Duration(milliseconds: 100),
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
placeholder,
|
||||
FadeInImage(
|
||||
fadeInDuration: duration,
|
||||
image: image,
|
||||
fit: fit,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
mobile/lib/shared/ui/hooks/blurhash_hook.dart
Normal file
17
mobile/lib/shared/ui/hooks/blurhash_hook.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:thumbhash/thumbhash.dart' as thumbhash;
|
||||
|
||||
ObjectRef<Uint8List?> useBlurHashRef(Asset? asset) {
|
||||
if (asset?.thumbhash == null) {
|
||||
return useRef(null);
|
||||
}
|
||||
|
||||
final rbga = thumbhash.thumbHashToRGBA(
|
||||
base64Decode(asset!.thumbhash!),
|
||||
);
|
||||
|
||||
return useRef(thumbhash.rgbaToBmp(rbga));
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -9,8 +7,6 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.d
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
|
||||
|
||||
class ImmichImage extends StatelessWidget {
|
||||
const ImmichImage(
|
||||
@@ -19,8 +15,6 @@ class ImmichImage extends StatelessWidget {
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder = const ThumbnailPlaceholder(),
|
||||
this.isThumbnail = false,
|
||||
this.thumbnailSize = 250,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -29,32 +23,6 @@ class ImmichImage extends StatelessWidget {
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final bool isThumbnail;
|
||||
final int thumbnailSize;
|
||||
|
||||
/// Factory constructor to use the thumbnail variant
|
||||
factory ImmichImage.thumbnail(
|
||||
Asset? asset, {
|
||||
BoxFit fit = BoxFit.cover,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
// Use the width and height to derive thumbnail size
|
||||
final thumbnailSize = max(width ?? 250, height ?? 250).toInt();
|
||||
|
||||
return ImmichImage(
|
||||
asset,
|
||||
isThumbnail: true,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
placeholder: ThumbnailPlaceholder(
|
||||
height: thumbnailSize.toDouble(),
|
||||
width: thumbnailSize.toDouble(),
|
||||
),
|
||||
thumbnailSize: thumbnailSize,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to return the image provider for the asset
|
||||
// either by using the asset ID or the asset itself
|
||||
@@ -66,8 +34,6 @@ class ImmichImage extends StatelessWidget {
|
||||
static ImageProvider imageProvider({
|
||||
Asset? asset,
|
||||
String? assetId,
|
||||
bool isThumbnail = false,
|
||||
int thumbnailSize = 250,
|
||||
}) {
|
||||
if (asset == null && assetId == null) {
|
||||
throw Exception('Must supply either asset or assetId');
|
||||
@@ -76,44 +42,36 @@ class ImmichImage extends StatelessWidget {
|
||||
if (asset == null) {
|
||||
return ImmichRemoteImageProvider(
|
||||
assetId: assetId!,
|
||||
isThumbnail: isThumbnail,
|
||||
isThumbnail: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (useLocal(asset) && isThumbnail) {
|
||||
return AssetEntityImageProvider(
|
||||
asset.local!,
|
||||
isOriginal: false,
|
||||
thumbnailSize: ThumbnailSize.square(thumbnailSize),
|
||||
);
|
||||
} else if (useLocal(asset) && !isThumbnail) {
|
||||
if (useLocal(asset)) {
|
||||
return ImmichLocalImageProvider(
|
||||
asset: asset,
|
||||
);
|
||||
} else {
|
||||
return ImmichRemoteImageProvider(
|
||||
assetId: asset.remoteId!,
|
||||
isThumbnail: isThumbnail,
|
||||
isThumbnail: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Whether to use the local asset image provider or a remote one
|
||||
static bool useLocal(Asset asset) =>
|
||||
!asset.isRemote ||
|
||||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (asset == null) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.grey,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: Icon(Icons.no_photography),
|
||||
),
|
||||
color: Colors.grey,
|
||||
width: width,
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: Icon(Icons.no_photography),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -131,7 +89,6 @@ class ImmichImage extends StatelessWidget {
|
||||
},
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
isThumbnail: isThumbnail,
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
|
||||
88
mobile/lib/shared/ui/immich_thumbnail.dart
Normal file
88
mobile/lib/shared/ui/immich_thumbnail.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/thumbhash_placeholder.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class ImmichThumbnail extends HookWidget {
|
||||
const ImmichThumbnail({
|
||||
this.asset,
|
||||
this.width = 250,
|
||||
this.height = 250,
|
||||
this.fit = BoxFit.cover,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Asset? asset;
|
||||
final double width;
|
||||
final double height;
|
||||
final BoxFit fit;
|
||||
|
||||
/// Helper function to return the image provider for the asset thumbnail
|
||||
/// either by using the asset ID or the asset itself
|
||||
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||
/// image provider
|
||||
static ImageProvider imageProvider({
|
||||
Asset? asset,
|
||||
String? assetId,
|
||||
int thumbnailSize = 256,
|
||||
}) {
|
||||
if (asset == null && assetId == null) {
|
||||
throw Exception('Must supply either asset or assetId');
|
||||
}
|
||||
|
||||
if (asset == null) {
|
||||
return ImmichRemoteImageProvider(
|
||||
assetId: assetId!,
|
||||
isThumbnail: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (ImmichImage.useLocal(asset)) {
|
||||
return ImmichLocalThumbnailProvider(
|
||||
asset: asset,
|
||||
height: thumbnailSize,
|
||||
width: thumbnailSize,
|
||||
);
|
||||
} else {
|
||||
return ImmichRemoteImageProvider(
|
||||
assetId: asset.remoteId!,
|
||||
isThumbnail: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Uint8List? blurhash = useBlurHashRef(asset).value;
|
||||
if (asset == null) {
|
||||
return Container(
|
||||
color: Colors.grey,
|
||||
width: width,
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: Icon(Icons.no_photography),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return OctoImage.fromSet(
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
octoSet: blurHashOrPlaceholder(blurhash),
|
||||
image: ImmichThumbnail.imageProvider(
|
||||
asset: asset,
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
}
|
||||
}
|
||||
48
mobile/lib/shared/ui/thumbhash_placeholder.dart
Normal file
48
mobile/lib/shared/ui/thumbhash_placeholder.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/shared/ui/fade_in_placeholder_image.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
|
||||
/// placeholder and [OctoError.icon] as error.
|
||||
OctoSet blurHashOrPlaceholder(
|
||||
Uint8List? blurhash, {
|
||||
BoxFit? fit,
|
||||
Text? errorMessage,
|
||||
}) {
|
||||
return OctoSet(
|
||||
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit),
|
||||
errorBuilder: blurHashErrorBuilder(blurhash, fit: fit),
|
||||
);
|
||||
}
|
||||
|
||||
OctoPlaceholderBuilder blurHashPlaceholderBuilder(
|
||||
Uint8List? blurhash, {
|
||||
BoxFit? fit,
|
||||
}) {
|
||||
return (context) => blurhash == null
|
||||
? const ThumbnailPlaceholder()
|
||||
: FadeInPlaceholderImage(
|
||||
placeholder: const ThumbnailPlaceholder(),
|
||||
image: MemoryImage(blurhash),
|
||||
fit: fit ?? BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
OctoErrorBuilder blurHashErrorBuilder(
|
||||
Uint8List? blurhash, {
|
||||
BoxFit? fit,
|
||||
Text? message,
|
||||
IconData? icon,
|
||||
Color? iconColor,
|
||||
double? iconSize,
|
||||
}) {
|
||||
return OctoError.placeholderWithErrorIcon(
|
||||
blurHashPlaceholderBuilder(blurhash, fit: fit),
|
||||
message: message,
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
iconSize: iconSize,
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var isDarkTheme = context.isDarkTheme;
|
||||
|
||||
buildStackMessage(String stackTrace) {
|
||||
buildTextWithCopyButton(String header, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
@@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"STACK TRACES",
|
||||
header,
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
color: context.primaryColor,
|
||||
@@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: stackTrace))
|
||||
.then((_) {
|
||||
Clipboard.setData(ClipboardData(text: text)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: SelectableText(
|
||||
stackTrace,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: "Inconsolata",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildLogMessage(String message) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"MESSAGE",
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: message)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Copied to clipboard",
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.copy,
|
||||
size: 16.0,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: SelectableText(
|
||||
message,
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget {
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
children: [
|
||||
buildLogMessage(logMessage.message),
|
||||
buildTextWithCopyButton("MESSAGE", logMessage.message),
|
||||
if (logMessage.details != null)
|
||||
buildTextWithCopyButton("DETAILS", logMessage.details.toString()),
|
||||
if (logMessage.context1 != null)
|
||||
buildLogContext1(logMessage.context1.toString()),
|
||||
if (logMessage.context2 != null)
|
||||
buildStackMessage(logMessage.context2.toString()),
|
||||
buildTextWithCopyButton(
|
||||
"STACK TRACE",
|
||||
logMessage.context2.toString(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
"Logs - ${logMessages.value.length}",
|
||||
style: const TextStyle(
|
||||
title: const Text(
|
||||
"Logs",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
@@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget {
|
||||
dense: true,
|
||||
tileColor: getTileColor(logMessage.level),
|
||||
minLeadingWidth: 10,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "#$index ",
|
||||
style: TextStyle(
|
||||
color: isDarkTheme ? Colors.white70 : Colors.grey[600],
|
||||
fontSize: 14.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: truncateLogMessage(logMessage.message, 4),
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
title: Text(
|
||||
truncateLogMessage(logMessage.message, 4),
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
fontFamily: "Inconsolata",
|
||||
),
|
||||
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
|
||||
),
|
||||
subtitle: Text(
|
||||
"[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
|
||||
"at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
color: Colors.grey[600],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||
|
||||
final _loadingEntry = OverlayEntry(
|
||||
builder: (context) => SizedBox.square(
|
||||
@@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
|
||||
child: DecoratedBox(
|
||||
decoration:
|
||||
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
|
||||
child: const Center(child: ImmichLoadingIndicator()),
|
||||
child: const Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
delay: Duration(seconds: 1),
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
|
||||
|
||||
class _LoadingOverlayState
|
||||
extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
|
||||
late final _isProcessing = ValueNotifier(false)..addListener(_listener);
|
||||
OverlayEntry? overlayEntry;
|
||||
late final _isLoading = ValueNotifier(false)..addListener(_listener);
|
||||
OverlayEntry? _loadingOverlay;
|
||||
|
||||
void _listener() {
|
||||
setState(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_isProcessing.value) {
|
||||
overlayEntry?.remove();
|
||||
overlayEntry = _loadingEntry;
|
||||
if (_isLoading.value) {
|
||||
_loadingOverlay?.remove();
|
||||
_loadingOverlay = _loadingEntry;
|
||||
Overlay.of(context).insert(_loadingEntry);
|
||||
} else {
|
||||
overlayEntry?.remove();
|
||||
overlayEntry = null;
|
||||
_loadingOverlay?.remove();
|
||||
_loadingOverlay = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -47,17 +52,17 @@ class _LoadingOverlayState
|
||||
|
||||
@override
|
||||
ValueNotifier<bool> build(BuildContext context) {
|
||||
return _isProcessing;
|
||||
return _isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isProcessing.dispose();
|
||||
_isLoading.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Object? get debugValue => _isProcessing.value;
|
||||
Object? get debugValue => _isLoading.value;
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useProcessingOverlay<>';
|
||||
|
||||
@@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||
deviceIsOffline = true;
|
||||
log.fine("Device seems to be offline upon launch");
|
||||
} else {
|
||||
log.severe(e);
|
||||
log.severe("Failed to resolve endpoint", e);
|
||||
}
|
||||
} catch (e) {
|
||||
log.severe(e);
|
||||
log.severe("Failed to resolve endpoint", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||
ref.read(authenticationProvider.notifier).logout();
|
||||
|
||||
log.severe(
|
||||
'Cannot set success login info: $error',
|
||||
'Cannot set success login info',
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
|
||||
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@@ -108,6 +108,7 @@ doc/PersonResponseDto.md
|
||||
doc/PersonStatisticsResponseDto.md
|
||||
doc/PersonUpdateDto.md
|
||||
doc/PersonWithFacesResponseDto.md
|
||||
doc/PlacesResponseDto.md
|
||||
doc/QueueStatusDto.md
|
||||
doc/ReactionLevel.md
|
||||
doc/ReactionType.md
|
||||
@@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
|
||||
lib/model/person_statistics_response_dto.dart
|
||||
lib/model/person_update_dto.dart
|
||||
lib/model/person_with_faces_response_dto.dart
|
||||
lib/model/places_response_dto.dart
|
||||
lib/model/queue_status_dto.dart
|
||||
lib/model/reaction_level.dart
|
||||
lib/model/reaction_type.dart
|
||||
@@ -485,6 +487,7 @@ test/person_response_dto_test.dart
|
||||
test/person_statistics_response_dto_test.dart
|
||||
test/person_update_dto_test.dart
|
||||
test/person_with_faces_response_dto_test.dart
|
||||
test/places_response_dto_test.dart
|
||||
test/queue_status_dto_test.dart
|
||||
test/reaction_level_test.dart
|
||||
test/reaction_type_test.dart
|
||||
|
||||
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.95.1
|
||||
- API version: 1.97.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
@@ -166,6 +166,7 @@ Class | Method | HTTP request | Description
|
||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
||||
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
|
||||
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
|
||||
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
|
||||
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
|
||||
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
|
||||
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
|
||||
@@ -306,6 +307,7 @@ Class | Method | HTTP request | Description
|
||||
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
||||
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
||||
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
|
||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||
- [QueueStatusDto](doc//QueueStatusDto.md)
|
||||
- [ReactionLevel](doc//ReactionLevel.md)
|
||||
- [ReactionType](doc//ReactionType.md)
|
||||
|
||||
1
mobile/openapi/doc/PeopleResponseDto.md
generated
1
mobile/openapi/doc/PeopleResponseDto.md
generated
@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**hidden** | **int** | |
|
||||
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [default to const []]
|
||||
**total** | **int** | |
|
||||
|
||||
|
||||
19
mobile/openapi/doc/PlacesResponseDto.md
generated
Normal file
19
mobile/openapi/doc/PlacesResponseDto.md
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
# openapi.model.PlacesResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**admin1name** | **String** | | [optional]
|
||||
**admin2name** | **String** | | [optional]
|
||||
**latitude** | **num** | |
|
||||
**longitude** | **num** | |
|
||||
**name** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user