Compare commits

..

66 Commits

Author SHA1 Message Date
Marty Fuhry 45d4984fde blurhash fade to thumb 2024-02-27 09:34:44 -05:00
Marty Fuhry ef2f605635 Fixes an issue where thumbnails fail to load if too many thumbnail requests are made simultaenously 2024-02-27 08:22:20 -05:00
Alex Tran 4532db552e Merge branch 'main' of github.com:immich-app/immich into refactor/immich-thumbnail 2024-02-26 21:26:25 -06:00
martyfuhry d799bf7910 fix(mobile): Stop sending app to login page for unrelated auth errors (#7383)
Now we only validate access token when we have one in the store and only send you to the login page when the response from the server is a 401

linter

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-26 21:25:39 -06:00
Michel Heusschen 4272b496ff fix(web): prevent resetting date input when entering 0 (#7415)
* fix(web): prevent resetting date input when entering 0

* resolve conflict

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-26 21:07:49 -06:00
Michel Heusschen 99a002b947 fix(web): alignment of people in search box (#7430)
* refactor search suggestion handling

* chore: open api

* revert server changes

* chore: open api

* update location filters

* location filter cleanup

* refactor people filter

* refactor camera filter

* refactor display filter

* cleanup

* fix(web): alignment of people in search box

* fix bad merge

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-26 21:07:23 -06:00
Michel Heusschen c8bdeb8fec fix(web): fetch error reporting (#7391) 2024-02-26 20:48:47 -06:00
martin 8a05ff51e9 fix(web): count hidden people (#7417)
fix: count hidden people
2024-02-26 15:58:52 -06:00
Daniel Dietzler 3e8af16270 refactor(web): search box (#7397)
* refactor search suggestion handling

* chore: open api

* revert server changes

* chore: open api

* update location filters

* location filter cleanup

* refactor people filter

* refactor camera filter

* refactor display filter

* cleanup

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2024-02-26 15:45:08 -06:00
Mert 45ecb629a1 fix(server): storage template migration not working (#7414)
add `withExif`
2024-02-25 13:18:09 -05:00
renovate[bot] 943105ea20 chore(deps): update vitest monorepo to v1.3.1 (#7407)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-25 09:07:13 -08:00
Marty Fuhry b9d438006c Fixes fade in duration for fade in placeholder 2024-02-24 20:49:43 -05:00
Marty Fuhry 1eb96fa9e9 Uses blurhash ref instead of state 2024-02-24 20:48:57 -05:00
Sourav Agrawal 2a75f884d9 Fix Smart Search when using OpenVINO (#7389)
* Fix external_path loading in OpenVINO EP

* Fix ruff lint

* Wrap block in try finally

* remove static input shape code

* add unit test

* remove unused imports

* remove repeat line

* linting

* formatting

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2024-02-24 18:22:27 -05:00
Daniel Mendizabal 912d723281 Update reverse-proxy.md - Apache (#7386)
Update reverse-proxy.md

Update to the Apache implementation
2024-02-24 14:31:01 -06:00
Jan108 038e69fd02 feat(web): Added password field visibility toggle (#7368)
* Added password field visibility toggle

* improvements to password input field

* fix e2e and change tabindex

* add missing name=password

* remove unnecessary type prop

---------

Co-authored-by: Jan108 <dasJan108@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2024-02-24 14:28:56 -06:00
Michel Heusschen 9d3ed719e0 fix(web): prevent scroll reset on search page (#7385) 2024-02-24 14:24:24 -06:00
Michel Heusschen 6ec4c5874b fix(web): timezone handling in search filter (#7384) 2024-02-24 14:23:30 -06:00
Robert Vollmer 57758293e5 fix(mobile): remove log message counter (#6865)
* fix(mobile): remove log message counter

Previously, the items in the log page were numbered starting with `#0`
and increasing from top to bottom. Being new to the app, this confused
me because I would have expected that newer messages have a higher
number than older messages.

Removing the counter completely because it doesn't add any value -
log messages are identified by their timestamp, text and other details.

* Switch timestamp and context in log overview
2024-02-23 21:40:09 -06:00
Robert Vollmer bc3979029d refactor(mobile): move error details to separate DB column (#6898)
* Add "details" column to LoggerMessage

* Include error details in log details page

* Move error details out of log message

* Add error message to mixin

* Create extension for HTTP Response logging

* Fix analyze errors

* format

* fix analyze errors, format again
2024-02-23 21:38:57 -06:00
Michel Heusschen 878932f87e feat(web): improve search filter design (#7367)
* feat(web): improve search filter design

* restore position of people toggle button

* consistent colors for media type inputs
2024-02-23 21:32:56 -06:00
martin a2934b8830 feat(server, web): search location (#7139)
* feat: search location

* fix: tests

* feat: outclick

* location search index

* update query

* fixed query

* updated sql

* update query

* Update search.dto.ts

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* coalesce

* fix: tests

* feat: add alternate names

* fix: generate sql files

* single table, add alternate names to query, cleanup

* merge main

* update sql

* pr feedback

* pr feedback

* chore: fix merge

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2024-02-23 19:42:37 -05:00
AndyPro720 719dbcc4d0 Web: Revamp message for Storage Template Engine in admin pannel (#7359)
* Web: Revamp message for Storage Template Engine in admin pannel

* Web: Revamp message for Storage Template Engine in admin pannel: removed unnessary code
2024-02-23 17:18:19 +00:00
hrdl cc6de7d1f1 fix(mobile): don't crop memories in landscape mode (#6907)
Don't crop memories in landscape mode unless aspect ratios are close

Co-authored-by: hrdl <7808331-hrdl@users.noreply.gitlab.com>
2024-02-23 17:05:36 +00:00
Sebastian Mahr 78ece4ced9 fix(web): dark mode uploading font color (#7372)
* fix: dark mode uploading font color

* chore: remove dark text by default
2024-02-23 17:05:09 +00:00
renovate[bot] ee58569b42 fix(deps): update dependency flutter_udid to v3 (#7248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-23 16:25:25 +00:00
renovate[bot] 07d747221e fix(deps): update dependency geolocator to v11 (#7249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-23 16:10:49 +00:00
martyfuhry 3cd3411c1f refactor(mobile): Use hooks to manage Chewie controller for video (#7008)
* video loading delayed

* Chewie controller implemented in hook

* fixing look and feel

* Finalizing delay and animations

* Fixes issue with immersive mode showing immediately in videos

* format fix

* Fixes bug where video controls would hide immediately after showing while playing and reverts hide controls timer to 5 seconds

* Fixed rebase issues

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-22 23:18:02 -06:00
martin b3b6426695 feat(web): configure slideshow (#7219)
* feat: configure slideshow delay

* feat: show/hide progressbar

* fix: slider

* refactor: use grid instead of flex

* fix: default delay

* refactor: progress bar props

* refactor: slideshow settings

* fix: enforce min/max value

* chore: linting

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-02-22 23:01:19 -06:00
Michel Heusschen 6bb30291de fix(web): consistent combobox style + improve color contrast (#7353) 2024-02-22 13:08:55 -05:00
Ben McCann 2c9dd18f1b fix: upgrade SvelteKit to 2.5.1 (#7351) 2024-02-22 12:58:48 -05:00
Michel Heusschen 07ef008b40 fix(server): exclude archived assets from orphaned files (#7334)
* fix(server): exclude archived assets from orphaned files

* add e2e test
2024-02-22 11:16:10 -05:00
Jason Rasmussen b3131dfe14 refactor(web): sidebar settings (#7344) 2024-02-22 10:14:11 -05:00
Ravid Yael b4e924b0c0 web: improve storage template onboarding message (#7339)
* Style: modifing onboard message for using storage template engine

* Style: modifying onboard message for using storage template engine

* style: Fix Prettier formatting issues

* chore: cleanup message

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-02-22 15:14:02 +00:00
martin 01d6707b59 feat(web): add an option to change the date formats (#7174)
* feat: add an option to change the date formats

* pr feedback

* fix: change title

* fix: show list supported by the browser

* fix: tests

* fix: dates

* fix: check only if locale is set

* fix: better fallback value

* fix: fallback

* fix: fallback

* feat: add default locale option

* refactor: shared components

* refactor: shared components

* prepare for svelte 5

* don't use relative paths

* refactor: fallback value

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* fix: parsing store

* fix: lint

* refactor: locales

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-02-22 14:36:14 +00:00
荣顶 a224bb23d0 docs: add star history for README (#7328) 2024-02-22 09:05:13 -05:00
martin 75947ab6c2 feat(web): search albums (#7322)
* feat: search albums

* pr feedback

* fix: comparison

* pr feedback

* simplify

* chore: more compact album padding

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-02-22 09:04:43 -05:00
Michel Heusschen e3cccba78c fix(server): out of memory when unstacking assets (#7332) 2024-02-22 08:50:46 -05:00
Michel Heusschen ec55acc98c perf(server): optimize mapAsset (#7331) 2024-02-22 08:48:27 -05:00
renovate[bot] 869e9f1399 chore(deps): update base-image to v20240222 (major) (#7338)
chore(deps): update base-image to v20240222

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 08:16:56 -05:00
Michel Heusschen 46f85618db feat(web): rework combobox and add clear button (#7317)
* feat(web): rework combobox

* simplify statement and use transition-all
2024-02-22 08:12:33 -05:00
Marty Fuhry c2694996e5 Uses blurhash hook state 2024-02-21 20:13:47 -05:00
Marty Fuhry 4d60133504 uses hook instead of stateful widget to be more consistent 2024-02-21 20:07:34 -05:00
Mert 749b182f97 fix(server): fix log for setting other vector extension (#7325)
fix log
2024-02-21 18:47:43 -05:00
Jason Rasmussen 2ebb57cbd4 refactor: e2e client (#7324) 2024-02-21 17:34:11 -05:00
martin 5c0c98473d fix(server, web): people page (#7319)
* fix: people page

* fix: use locale

* fix: e2e

* fix: remove useless w-full

* fix: don't count people without thumbnail

* fix: es6 template string

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-02-21 17:03:45 -05:00
Jason Rasmussen 546edc2e91 refactor: album e2e (#7320)
* refactor: album e2e

* refactor: user e2e
2024-02-21 16:52:13 -05:00
Marty Fuhry c0ef300040 Fixes image blur 2024-02-21 12:11:38 -05:00
Michel Heusschen 173b47033a fix(server): search with same face multiple times (#7306) 2024-02-21 09:52:38 -06:00
Michel Heusschen d3e14fd662 feat(web): search improvements and refactor (#7291) 2024-02-21 09:50:50 -06:00
Marty Fuhry b05d4fa7d3 Adds blurhash
format
2024-02-21 10:48:36 -05:00
Marty Fuhry 84cd91bbbe dart format
linter errors

linter
2024-02-21 09:04:33 -05:00
Marcel Eeken 06c134950a Localize the output of the library count to make it more readable (#7305) 2024-02-21 14:35:24 +01:00
martin 8f57bfb496 fix(web): small issues everywhere (#7207)
* multiple fix

* fix: album re-render

* fix: revert re-render album

* fix: linter

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-21 08:29:22 -05:00
Michel Heusschen 855aa8e30a fix(web): back button for gallery viewer (#7250) 2024-02-21 08:28:16 -05:00
Jason Rasmussen f798e037d5 refactor(server): e2e (#7265)
* refactor: activity e2e

* refactor: person e2e

* refactor: shared link e2e
2024-02-21 08:28:03 -05:00
renovate[bot] a1bc74cdd6 fix(deps): update exiftool (#7230)
* fix(deps): update exiftool

* documenting such changes would have been too easy

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2024-02-21 08:26:13 -05:00
renovate[bot] aeb7081af1 chore(deps): update @immich/cli (#7235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 08:25:59 -05:00
renovate[bot] c5da317033 chore(deps): update dependency @types/node to v20.11.19 (#7236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 08:25:45 -05:00
renovate[bot] 01f682134a chore(deps): update dependency @types/node to v20.11.19 (#7238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 08:25:37 -05:00
renovate[bot] 43f887e5f2 chore(deps): update dependency @types/node to v20.11.19 (#7239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 08:25:28 -05:00
renovate[bot] ee3b3ca115 chore(deps): update dependency vite to v5.1.3 (#7247)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 08:24:50 -05:00
Michel Heusschen e7995014f9 fix(web): search filter form events (#7285) 2024-02-21 08:21:43 -05:00
renovate[bot] a771f33fa3 chore(deps): update machine-learning (#7225) 2024-02-21 01:12:38 -05:00
Mert 397570ad1a chore(server): change transcode default to accept all supported audio codecs (#7283)
* change transcode defaults

* don't untick accepted audio codecs

* no need to change the transcode policy

* fix tests

* remove log
2024-02-21 00:25:30 -05:00
Marty Fuhry 718c258a07 Refactor to use ImmichThumbnail and local thumbnail image provider
format
2024-02-20 22:10:38 -05:00
240 changed files with 5603 additions and 3380 deletions
+6
View File
@@ -128,3 +128,9 @@ If you feel like this is the right cause and the app is something you are seeing
<a href="https://github.com/alextran1502/immich/graphs/contributors"> <a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/> <img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a> </a>
## Star History
<a href="https://star-history.com/#immich-app/immich">
<img src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" alt="Star History Chart" width="100%" />
</a>
+12 -12
View File
@@ -1325,9 +1325,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@@ -5240,9 +5240,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.1.2", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==", "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
@@ -6481,9 +6481,9 @@
} }
}, },
"@types/node": { "@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@@ -9364,9 +9364,9 @@
} }
}, },
"vite": { "vite": {
"version": "5.1.2", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==", "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true, "dev": true,
"requires": { "requires": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
+2 -2
View File
@@ -17,7 +17,7 @@ x-server-build: &server-common
services: services:
immich-server: immich-server:
container_name: immich_server container_name: immich_server
command: [ "./start-server.sh" ] command: [ "start.sh", "immich" ]
<<: *server-common <<: *server-common
ports: ports:
- 2283:3001 - 2283:3001
@@ -27,7 +27,7 @@ services:
immich-microservices: immich-microservices:
container_name: immich_microservices container_name: immich_microservices
command: [ "./start-microservices.sh" ] command: [ "start.sh", "microservices" ]
<<: *server-common <<: *server-common
# extends: # extends:
# file: hwaccel.transcoding.yml # file: hwaccel.transcoding.yml
+7 -16
View File
@@ -44,22 +44,13 @@ Below is an example config for Apache2 site configuration.
``` ```
<VirtualHost *:80> <VirtualHost *:80>
ServerName <snip> ServerName <snip>
ProxyRequests Off
ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
ProxyPassReverse / http://127.0.0.1:2283/
ProxyPreserveHost On
ProxyRequests off
ProxyVia on
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/api/socket.io [NC]
RewriteCond %{QUERY_STRING} transport=websocket [NC]
RewriteRule /(.*) ws://localhost:2283/$1 [P,L]
ProxyPass /api/socket.io ws://localhost:2283/api/socket.io
ProxyPassReverse /api/socket.io ws://localhost:2283/api/socket.io
<Location />
ProxyPass http://localhost:2283/
ProxyPassReverse http://localhost:2283/
</Location>
</VirtualHost> </VirtualHost>
``` ```
**timeout:** is measured in seconds, and it is particularly useful when long operations are triggered (i.e. Repair), so the server doesn't return an error.
+1
View File
@@ -4,6 +4,7 @@ name: immich-e2e
x-server-build: &server-common x-server-build: &server-common
image: immich-server:latest image: immich-server:latest
container_name: immich-e2e-server
build: build:
context: ../ context: ../
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
+47 -42
View File
@@ -23,9 +23,13 @@
} }
}, },
"../cli": { "../cli": {
"name": "@immich/cli",
"version": "2.0.8", "version": "2.0.8",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": {
"lodash-es": "^4.17.21"
},
"bin": { "bin": {
"immich": "dist/index.js" "immich": "dist/index.js"
}, },
@@ -34,6 +38,7 @@
"@testcontainers/postgresql": "^10.7.1", "@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -801,9 +806,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@@ -899,9 +904,9 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz",
"integrity": "sha512-e5Y5uK5NNoQMQaNitGQQjo9FoA5ZNcu7Bn6pH+dxUf48u6po1cX38kFBYUHZ9GNVkF4JLbncE0WeWwTw+nLrxg==", "integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.1", "@ampproject/remapping": "^2.2.1",
@@ -922,17 +927,17 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"vitest": "1.3.0" "vitest": "1.3.1"
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
"integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==", "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/spy": "1.3.0", "@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"chai": "^4.3.10" "chai": "^4.3.10"
}, },
"funding": { "funding": {
@@ -940,12 +945,12 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
"integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==", "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"p-limit": "^5.0.0", "p-limit": "^5.0.0",
"pathe": "^1.1.1" "pathe": "^1.1.1"
}, },
@@ -954,9 +959,9 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
"integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==", "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"magic-string": "^0.30.5", "magic-string": "^0.30.5",
@@ -968,9 +973,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
"integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==", "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"tinyspy": "^2.2.0" "tinyspy": "^2.2.0"
@@ -980,9 +985,9 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
"integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==", "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"diff-sequences": "^29.6.3", "diff-sequences": "^29.6.3",
@@ -2546,9 +2551,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.1.3", "version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
"integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", "integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
@@ -2601,9 +2606,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
"integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==", "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
@@ -2637,16 +2642,16 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
"integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==", "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.3.0", "@vitest/expect": "1.3.1",
"@vitest/runner": "1.3.0", "@vitest/runner": "1.3.1",
"@vitest/snapshot": "1.3.0", "@vitest/snapshot": "1.3.1",
"@vitest/spy": "1.3.0", "@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"acorn-walk": "^8.3.2", "acorn-walk": "^8.3.2",
"chai": "^4.3.10", "chai": "^4.3.10",
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -2660,7 +2665,7 @@
"tinybench": "^2.5.1", "tinybench": "^2.5.1",
"tinypool": "^0.8.2", "tinypool": "^0.8.2",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-node": "1.3.0", "vite-node": "1.3.1",
"why-is-node-running": "^2.2.2" "why-is-node-running": "^2.2.2"
}, },
"bin": { "bin": {
@@ -2675,8 +2680,8 @@
"peerDependencies": { "peerDependencies": {
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0", "@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.3.0", "@vitest/browser": "1.3.1",
"@vitest/ui": "1.3.0", "@vitest/ui": "1.3.1",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
@@ -1,79 +1,94 @@
import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain'; import {
import { ActivityController } from '@app/immich'; ActivityCreateDto,
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; AlbumResponseDto,
import { ActivityEntity } from '@app/infra/entities'; AssetResponseDto,
import { errorStub, userDto, uuidStub } from '@test/fixtures'; LoginResponseDto,
ReactionType,
createActivity as create,
createAlbum,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { api } from '../../client'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { testApp } from '../utils';
describe(`${ActivityController.name} (e2e)`, () => { describe('/activity', () => {
let server: any;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
let nonOwner: LoginResponseDto; let nonOwner: LoginResponseDto;
let asset: AssetResponseDto;
let album: AlbumResponseDto;
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
create(
{ activityCreateDto: dto },
{ headers: asBearerAuth(accessToken || admin.accessToken) }
);
beforeAll(async () => { beforeAll(async () => {
server = (await testApp.create()).getHttpServer(); apiUtils.setup();
await testApp.reset(); await dbUtils.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
asset = await api.assetApi.upload(server, admin.accessToken, 'example');
await api.userApi.create(server, admin.accessToken, userDto.user1); admin = await apiUtils.adminSetup();
nonOwner = await api.authApi.login(server, userDto.user1); nonOwner = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
asset = await apiUtils.createAsset(admin.accessToken);
album = await api.albumApi.create(server, admin.accessToken, { album = await createAlbum(
albumName: 'Album 1', {
assetIds: [asset.id], createAlbumDto: {
sharedWithUserIds: [nonOwner.userId], albumName: 'Album 1',
}); assetIds: [asset.id],
}); sharedWithUserIds: [nonOwner.userId],
},
afterAll(async () => { },
await testApp.teardown(); { headers: asBearerAuth(admin.accessToken) }
);
}); });
beforeEach(async () => { beforeEach(async () => {
await testApp.reset({ entities: [ActivityEntity] }); await dbUtils.reset(['activity']);
}); });
describe('GET /activity', () => { describe('GET /activity', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).get('/activity'); const { status, body } = await request(app).get('/activity');
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require an albumId', async () => { it('should require an albumId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
);
}); });
it('should reject an invalid albumId', async () => { it('should reject an invalid albumId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: uuidStub.invalid }) .query({ albumId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
);
}); });
it('should reject an invalid assetId', async () => { it('should reject an invalid assetId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid }) .query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['assetId must be a UUID']))); expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID']))
);
}); });
it('should start off empty', async () => { it('should start off empty', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id }) .query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -82,22 +97,22 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should filter by album id', async () => { it('should filter by album id', async () => {
const album2 = await api.albumApi.create(server, admin.accessToken, { const album2 = await createAlbum(
albumName: 'Album 2', {
assetIds: [asset.id], createAlbumDto: {
}); albumName: 'Album 2',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) }
);
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { createActivity({ albumId: album.id, type: ReactionType.Like }),
albumId: album.id, createActivity({ albumId: album2.id, type: ReactionType.Like }),
type: ReactionType.LIKE,
}),
api.activityApi.create(server, admin.accessToken, {
albumId: album2.id,
type: ReactionType.LIKE,
}),
]); ]);
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id }) .query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -108,15 +123,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by type=comment', async () => { it('should filter by type=comment', async () => {
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'comment', comment: 'comment',
}), }),
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), createActivity({ albumId: album.id, type: ReactionType.Like }),
]); ]);
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id, type: 'comment' }) .query({ albumId: album.id, type: 'comment' })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -127,15 +142,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by type=like', async () => { it('should filter by type=like', async () => {
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), createActivity({ albumId: album.id, type: ReactionType.Like }),
api.activityApi.create(server, admin.accessToken, { createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'comment', comment: 'comment',
}), }),
]); ]);
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id, type: 'like' }) .query({ albumId: album.id, type: 'like' })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -146,18 +161,18 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by userId', async () => { it('should filter by userId', async () => {
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), createActivity({ albumId: album.id, type: ReactionType.Like }),
]); ]);
const response1 = await request(server) const response1 = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id, userId: uuidStub.notFound }) .query({ albumId: album.id, userId: uuidDto.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(response1.status).toEqual(200); expect(response1.status).toEqual(200);
expect(response1.body.length).toBe(0); expect(response1.body.length).toBe(0);
const response2 = await request(server) const response2 = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id, userId: admin.userId }) .query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -169,15 +184,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
it('should filter by assetId', async () => { it('should filter by assetId', async () => {
const [reaction] = await Promise.all([ const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { createActivity({
albumId: album.id, albumId: album.id,
assetId: asset.id, assetId: asset.id,
type: ReactionType.LIKE, type: ReactionType.Like,
}), }),
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), createActivity({ albumId: album.id, type: ReactionType.Like }),
]); ]);
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/activity') .get('/activity')
.query({ albumId: album.id, assetId: asset.id }) .query({ albumId: album.id, assetId: asset.id })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -189,34 +204,45 @@ describe(`${ActivityController.name} (e2e)`, () => {
describe('POST /activity', () => { describe('POST /activity', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).post('/activity'); const { status, body } = await request(app).post('/activity');
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require an albumId', async () => { it('should require an albumId', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidStub.invalid }); .send({ albumId: uuidDto.invalid });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
);
}); });
it('should require a comment when type is comment', async () => { it('should require a comment when type is comment', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidStub.notFound, type: 'comment', comment: null }); .send({ albumId: uuidDto.notFound, type: 'comment', comment: null });
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['comment must be a string', 'comment should not be empty'])); expect(body).toEqual(
errorDto.badRequest([
'comment must be a string',
'comment should not be empty',
])
);
}); });
it('should add a comment to an album', async () => { it('should add a comment to an album', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'comment', comment: 'This is my first comment' }); .send({
albumId: album.id,
type: 'comment',
comment: 'This is my first comment',
});
expect(status).toEqual(201); expect(status).toEqual(201);
expect(body).toEqual({ expect(body).toEqual({
id: expect.any(String), id: expect.any(String),
@@ -229,7 +255,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should add a like to an album', async () => { it('should add a like to an album', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' }); .send({ albumId: album.id, type: 'like' });
@@ -245,11 +271,11 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should return a 200 for a duplicate like on the album', async () => { it('should return a 200 for a duplicate like on the album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const [reaction] = await Promise.all([
albumId: album.id, createActivity({ albumId: album.id, type: ReactionType.Like }),
type: ReactionType.LIKE, ]);
});
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' }); .send({ albumId: album.id, type: 'like' });
@@ -258,12 +284,14 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should not confuse an album like with an asset like', async () => { it('should not confuse an album like with an asset like', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const [reaction] = await Promise.all([
albumId: album.id, createActivity({
assetId: asset.id, albumId: album.id,
type: ReactionType.LIKE, assetId: asset.id,
}); type: ReactionType.Like,
const { status, body } = await request(server) }),
]);
const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' }); .send({ albumId: album.id, type: 'like' });
@@ -272,10 +300,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should add a comment to an asset', async () => { it('should add a comment to an asset', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'comment', comment: 'This is my first comment' }); .send({
albumId: album.id,
assetId: asset.id,
type: 'comment',
comment: 'This is my first comment',
});
expect(status).toEqual(201); expect(status).toEqual(201);
expect(body).toEqual({ expect(body).toEqual({
id: expect.any(String), id: expect.any(String),
@@ -288,7 +321,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should add a like to an asset', async () => { it('should add a like to an asset', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' }); .send({ albumId: album.id, assetId: asset.id, type: 'like' });
@@ -304,12 +337,15 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should return a 200 for a duplicate like on an asset', async () => { it('should return a 200 for a duplicate like on an asset', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const [reaction] = await Promise.all([
albumId: album.id, createActivity({
assetId: asset.id, albumId: album.id,
type: ReactionType.LIKE, assetId: asset.id,
}); type: ReactionType.Like,
const { status, body } = await request(server) }),
]);
const { status, body } = await request(app)
.post('/activity') .post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' }); .send({ albumId: album.id, assetId: asset.id, type: 'like' });
@@ -320,50 +356,52 @@ describe(`${ActivityController.name} (e2e)`, () => {
describe('DELETE /activity/:id', () => { describe('DELETE /activity/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/activity/${uuidStub.notFound}`); const { status, body } = await request(app).delete(
`/activity/${uuidDto.notFound}`
);
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/activity/${uuidStub.invalid}`) .delete(`/activity/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID'])); expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
}); });
it('should remove a comment from an album', async () => { it('should remove a comment from an album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const reaction = await createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'This is a test comment', comment: 'This is a test comment',
}); });
const { status } = await request(server) const { status } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204); expect(status).toEqual(204);
}); });
it('should remove a like from an album', async () => { it('should remove a like from an album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const reaction = await createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.LIKE, type: ReactionType.Like,
}); });
const { status } = await request(server) const { status } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204); expect(status).toEqual(204);
}); });
it('should let the owner remove a comment by another user', async () => { it('should let the owner remove a comment by another user', async () => {
const reaction = await api.activityApi.create(server, nonOwner.accessToken, { const reaction = await createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'This is a test comment', comment: 'This is a test comment',
}); });
const { status } = await request(server) const { status } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -371,28 +409,33 @@ describe(`${ActivityController.name} (e2e)`, () => {
}); });
it('should not let a user remove a comment by another user', async () => { it('should not let a user remove a comment by another user', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, { const reaction = await createActivity({
albumId: album.id, albumId: album.id,
type: ReactionType.COMMENT, type: ReactionType.Comment,
comment: 'This is a test comment', comment: 'This is a test comment',
}); });
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`); .set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no activity.delete access')); expect(body).toEqual(
errorDto.badRequest('Not found or no activity.delete access')
);
}); });
it('should let a non-owner remove their own comment', async () => { it('should let a non-owner remove their own comment', async () => {
const reaction = await api.activityApi.create(server, nonOwner.accessToken, { const reaction = await createActivity(
albumId: album.id, {
type: ReactionType.COMMENT, albumId: album.id,
comment: 'This is a test comment', type: ReactionType.Comment,
}); comment: 'This is a test comment',
},
nonOwner.accessToken
);
const { status } = await request(server) const { status } = await request(app)
.delete(`/activity/${reaction.id}`) .delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`); .set('Authorization', `Bearer ${nonOwner.accessToken}`);
@@ -1,11 +1,15 @@
import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; import {
import { AlbumController } from '@app/immich'; AlbumResponseDto,
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; AssetResponseDto,
import { SharedLinkType } from '@app/infra/entities'; LoginResponseDto,
import { errorStub, userDto, uuidStub } from '@test/fixtures'; SharedLinkType,
deleteUser,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { api } from '../../client'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { testApp } from '../utils';
const user1SharedUser = 'user1SharedUser'; const user1SharedUser = 'user1SharedUser';
const user1SharedLink = 'user1SharedLink'; const user1SharedLink = 'user1SharedLink';
@@ -14,193 +18,327 @@ const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink'; const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared'; const user2NotShared = 'user2NotShared';
describe(`${AlbumController.name} (e2e)`, () => { describe('/album', () => {
let server: any;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user1Asset: AssetFileUploadResponseDto; let user1Asset1: AssetResponseDto;
let user1Asset2: AssetResponseDto;
let user1Albums: AlbumResponseDto[]; let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto; let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[]; let user2Albums: AlbumResponseDto[];
let user3: LoginResponseDto; // deleted
beforeAll(async () => { beforeAll(async () => {
server = (await testApp.create()).getHttpServer(); apiUtils.setup();
}); await dbUtils.reset();
afterAll(async () => { admin = await apiUtils.adminSetup();
await testApp.teardown();
});
beforeEach(async () => { [user1, user2, user3] = await Promise.all([
await testApp.reset(); apiUtils.userSetup(admin.accessToken, createUserDto.user1),
await api.authApi.adminSignUp(server); apiUtils.userSetup(admin.accessToken, createUserDto.user2),
admin = await api.authApi.adminLogin(server); apiUtils.userSetup(admin.accessToken, createUserDto.user3),
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]); ]);
[user1, user2] = await Promise.all([ [user1Asset1, user1Asset2] = await Promise.all([
api.authApi.login(server, userDto.user1), apiUtils.createAsset(user1.accessToken),
api.authApi.login(server, userDto.user2), apiUtils.createAsset(user1.accessToken),
]); ]);
user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
const albums = await Promise.all([ const albums = await Promise.all([
// user 1 // user 1
api.albumApi.create(server, user1.accessToken, { apiUtils.createAlbum(user1.accessToken, {
albumName: user1SharedUser, albumName: user1SharedUser,
sharedWithUserIds: [user2.userId], sharedWithUserIds: [user2.userId],
assetIds: [user1Asset.id], assetIds: [user1Asset1.id],
}),
apiUtils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
apiUtils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}), }),
api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
// user 2 // user 2
api.albumApi.create(server, user2.accessToken, { apiUtils.createAlbum(user2.accessToken, {
albumName: user2SharedUser, albumName: user2SharedUser,
sharedWithUserIds: [user1.userId], sharedWithUserIds: [user1.userId],
assetIds: [user1Asset.id], assetIds: [user1Asset1.id],
}),
apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
// user 3
apiUtils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
sharedWithUserIds: [user1.userId],
}), }),
api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }),
api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
]); ]);
user1Albums = albums.slice(0, 3); user1Albums = albums.slice(0, 3);
user2Albums = albums.slice(3); user2Albums = albums.slice(3, 6);
await Promise.all([ await Promise.all([
// add shared link to user1SharedLink album // add shared link to user1SharedLink album
api.sharedLinkApi.create(server, user1.accessToken, { apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.ALBUM, type: SharedLinkType.Album,
albumId: user1Albums[1].id, albumId: user1Albums[1].id,
}), }),
// add shared link to user2SharedLink album // add shared link to user2SharedLink album
api.sharedLinkApi.create(server, user2.accessToken, { apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.ALBUM, type: SharedLinkType.Album,
albumId: user2Albums[1].id, albumId: user2Albums[1].id,
}), }),
]); ]);
await deleteUser(
{ id: user3.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
}); });
describe('GET /album', () => { describe('GET /album', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).get('/album'); const { status, body } = await request(app).get('/album');
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should reject an invalid shared param', async () => { it('should reject an invalid shared param', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/album?shared=invalid') .get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value'])); expect(body).toEqual(
errorDto.badRequest(['shared must be a boolean value'])
);
}); });
it('should reject an invalid assetId param', async () => { it('should reject an invalid assetId param', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/album?assetId=invalid') .get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID'])); expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
}); });
it('should not return shared albums with a deleted owner', async () => { it('should not return shared albums with a deleted owner', async () => {
await api.userApi.delete(server, admin.accessToken, user1.userId); const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/album?shared=true') .get('/album?shared=true')
.set('Authorization', `Bearer ${user2.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(3);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }), expect.objectContaining({
]), ownerId: user1.userId,
albumName: user1SharedLink,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedUser,
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
shared: true,
}),
])
); );
}); });
it('should return the album collection including owned and shared', async () => { it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`); const { status, body } = await request(app)
.get('/album')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(3); expect(body).toHaveLength(3);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), expect.objectContaining({
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), ownerId: user1.userId,
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), albumName: user1SharedUser,
]), shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1NotShared,
shared: false,
}),
])
); );
}); });
it('should return the album collection filtered by shared', async () => { it('should return the album collection filtered by shared', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/album?shared=true') .get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(3); expect(body).toHaveLength(3);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), expect.objectContaining({
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), ownerId: user1.userId,
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }), albumName: user1SharedUser,
]), shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
shared: true,
}),
])
); );
}); });
it('should return the album collection filtered by NOT shared', async () => { it('should return the album collection filtered by NOT shared', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/album?shared=false') .get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(1);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), expect.objectContaining({
]), ownerId: user1.userId,
albumName: user1NotShared,
shared: false,
}),
])
); );
}); });
it('should return the album collection filtered by assetId', async () => { it('should return the album collection filtered by assetId', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example2'); const { status, body } = await request(app)
await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] }); .get(`/album?assetId=${user1Asset2.id}`)
const { status, body } = await request(server)
.get(`/album?assetId=${asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(1);
}); });
it('should return the album collection filtered by assetId and ignores shared=true', async () => { it('should return the album collection filtered by assetId and ignores shared=true', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get(`/album?shared=true&assetId=${user1Asset.id}`) .get(`/album?shared=true&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(4); expect(body).toHaveLength(4);
}); });
it('should return the album collection filtered by assetId and ignores shared=false', async () => { it('should return the album collection filtered by assetId and ignores shared=false', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get(`/album?shared=false&assetId=${user1Asset.id}`) .get(`/album?shared=false&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(4); expect(body).toHaveLength(4);
}); });
}); });
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/album/${user1Albums[0].id}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return album info for own album', async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
});
});
it('should return album info for shared album', async () => {
const { status, body } = await request(app)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [expect.objectContaining(user2Albums[0].assets[0])],
});
});
it('should return album info with assets when withoutAssets is undefined', async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
});
});
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [],
assetCount: 1,
});
});
});
describe('GET /album/count', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/album/count');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(app)
.get('/album/count')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
});
});
describe('POST /album', () => { describe('POST /album', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).post('/album').send({ albumName: 'New album' }); const { status, body } = await request(app)
.post('/album')
.send({ albumName: 'New album' });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should create an album', async () => { it('should create an album', async () => {
const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); const { status, body } = await request(app)
.post('/album')
.send({ albumName: 'New album' })
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(201);
expect(body).toEqual({ expect(body).toEqual({
id: expect.any(String), id: expect.any(String),
createdAt: expect.any(String), createdAt: expect.any(String),
@@ -220,113 +358,56 @@ describe(`${AlbumController.name} (e2e)`, () => {
}); });
}); });
describe('GET /album/count', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album/count');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(server)
.get('/album/count')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
});
});
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return album info for own album', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
});
it('should return album info for shared album', async () => {
const { status, body } = await request(server)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] });
});
it('should return album info with assets when withoutAssets is undefined', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
});
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [],
assetCount: 1,
});
});
});
describe('PUT /album/:id/assets', () => { describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`); const { status, body } = await request(app).put(
`/album/${user1Albums[0].id}/assets`
);
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should be able to add own asset to own album', async () => { it('should be able to add own asset to own album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); const asset = await apiUtils.createAsset(user1.accessToken);
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/assets`) .put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] }); .send({ ids: [asset.id] });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
}); });
it('should be able to add own asset to shared album', async () => { it('should be able to add own asset to shared album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); const asset = await apiUtils.createAsset(user1.accessToken);
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${user2Albums[0].id}/assets`) .put(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] }); .send({ ids: [asset.id] });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
}); });
}); });
describe('PATCH /album/:id', () => { describe('PATCH /album/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/album/${uuidStub.notFound}`) .patch(`/album/${uuidDto.notFound}`)
.send({ albumName: 'New album name' }); .send({ albumName: 'New album name' });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should update an album', async () => { it('should update an album', async () => {
const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); const album = await apiUtils.createAlbum(user1.accessToken, {
const { status, body } = await request(server) albumName: 'New album',
});
const { status, body } = await request(app)
.patch(`/album/${album.id}`) .patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ .send({
@@ -345,52 +426,68 @@ describe(`${AlbumController.name} (e2e)`, () => {
describe('DELETE /album/:id/assets', () => { describe('DELETE /album/:id/assets', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`) .delete(`/album/${user1Albums[0].id}/assets`)
.send({ ids: [user1Asset.id] }); .send({ ids: [user1Asset1.id] });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
});
it('should be able to remove own asset from shared album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
}); });
it('should not be able to remove foreign asset from own album', async () => { it('should not be able to remove foreign asset from own album', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/album/${user2Albums[0].id}/assets`) .delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`) .set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] }); .send({ ids: [user1Asset1.id] });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); expect(body).toEqual([
expect.objectContaining({
id: user1Asset1.id,
success: false,
error: 'no_permission',
}),
]);
}); });
it('should not be able to remove foreign asset from foreign album', async () => { it('should not be able to remove foreign asset from foreign album', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`) .delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`) .set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] }); .send({ ids: [user1Asset1.id] });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); expect(body).toEqual([
expect.objectContaining({
id: user1Asset1.id,
success: false,
error: 'no_permission',
}),
]);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(app)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
});
it('should be able to remove own asset from shared album', async () => {
const { status, body } = await request(app)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
}); });
}); });
@@ -398,51 +495,57 @@ describe(`${AlbumController.name} (e2e)`, () => {
let album: AlbumResponseDto; let album: AlbumResponseDto;
beforeEach(async () => { beforeEach(async () => {
album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' }); album = await apiUtils.createAlbum(user1.accessToken, {
albumName: 'testAlbum',
});
}); });
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/users`) .put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] }); .send({ sharedUserIds: [] });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should be able to add user to own album', async () => { it('should be able to add user to own album', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ sharedUserIds: [user2.userId] });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] })); expect(body).toEqual(
expect.objectContaining({
sharedUsers: [expect.objectContaining({ id: user2.userId })],
})
);
}); });
it('should not be able to share album with owner', async () => { it('should not be able to share album with owner', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user1.userId] }); .send({ sharedUserIds: [user1.userId] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner')); expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
}); });
it('should not be able to add existing user to shared album', async () => { it('should not be able to add existing user to shared album', async () => {
await request(server) await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ sharedUserIds: [user2.userId] });
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ sharedUserIds: [user2.userId] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('User already added')); expect(body).toEqual(errorDto.badRequest('User already added'));
}); });
}); });
}); });
+51
View File
@@ -0,0 +1,51 @@
import {
deleteAssets,
getAuditFiles,
updateAsset,
type LoginResponseDto,
} from '@immich/sdk';
import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/audit', () => {
let admin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
await fileUtils.reset();
admin = await apiUtils.adminSetup();
});
describe('GET :/file-report', () => {
it('excludes assets without issues from report', async () => {
const [trashedAsset, archivedAsset, _] = await Promise.all([
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
]);
await Promise.all([
deleteAssets(
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
{ headers: asBearerAuth(admin.accessToken) }
),
updateAsset(
{
id: archivedAsset.id,
updateAssetDto: { isArchived: true },
},
{ headers: asBearerAuth(admin.accessToken) }
),
]);
const body = await getAuditFiles({
headers: asBearerAuth(admin.accessToken),
});
expect(body.orphans).toHaveLength(0);
expect(body.extras).toHaveLength(0);
});
});
});
+178
View File
@@ -0,0 +1,178 @@
import { LoginResponseDto, PersonResponseDto } from '@immich/sdk';
import { uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/activity', () => {
let admin: LoginResponseDto;
let visiblePerson: PersonResponseDto;
let hiddenPerson: PersonResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup();
});
beforeEach(async () => {
await dbUtils.reset(['person']);
[visiblePerson, hiddenPerson] = await Promise.all([
apiUtils.createPerson(admin.accessToken, {
name: 'visible_person',
}),
apiUtils.createPerson(admin.accessToken, {
name: 'hidden_person',
isHidden: true,
}),
]);
const asset = await apiUtils.createAsset(admin.accessToken);
await Promise.all([
dbUtils.createFace({ assetId: asset.id, personId: visiblePerson.id }),
dbUtils.createFace({ assetId: asset.id, personId: hiddenPerson.id }),
]);
});
describe('GET /person', () => {
beforeEach(async () => {});
it('should require authentication', async () => {
const { status, body } = await request(app).get('/person');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return all people (including hidden)', async () => {
const { status, body } = await request(app)
.get('/person')
.set('Authorization', `Bearer ${admin.accessToken}`)
.query({ withHidden: true });
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
hidden: 1,
people: [
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }),
],
});
});
it('should return only visible people', async () => {
const { status, body } = await request(app)
.get('/person')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
hidden: 1,
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
});
describe('GET /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/person/${uuidDto.notFound}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(app)
.get(`/person/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
it('should return person information', async () => {
const { status, body } = await request(app)
.get(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
});
});
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/person/${uuidDto.notFound}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
for (const { key, type } of [
{ key: 'name', type: 'string' },
{ key: 'featureFaceAssetId', type: 'string' },
{ key: 'isHidden', type: 'boolean value' },
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([`${key} must be a ${type}`]));
});
}
it('should not accept invalid birth dates', async () => {
for (const { birthDate, response } of [
{ birthDate: false, response: 'Not found or no person.write access' },
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
{
birthDate: '123567',
response: 'Not found or no person.write access',
},
{ birthDate: 123567, response: 'Not found or no person.write access' },
]) {
const { status, body } = await request(app)
.put(`/person/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(response));
}
});
it('should update a date of birth', async () => {
const { status, body } = await request(app)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: '1990-01-01T05:00:00.000Z' });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01' });
});
it('should clear a date of birth', async () => {
// TODO ironically this uses the update endpoint to create the person
const person = await apiUtils.createPerson(admin.accessToken, {
birthDate: new Date('1990-01-01').toISOString(),
});
expect(person.birthDate).toBeDefined();
const { status, body } = await request(app)
.put(`/person/${person.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: null });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: null });
});
});
});
@@ -1,21 +1,21 @@
import { import {
AlbumResponseDto, AlbumResponseDto,
AssetResponseDto, AssetResponseDto,
IAssetRepository,
LoginResponseDto, LoginResponseDto,
SharedLinkCreateDto,
SharedLinkResponseDto, SharedLinkResponseDto,
} from '@app/domain'; SharedLinkType,
import { SharedLinkController } from '@app/immich'; createSharedLink as create,
import { SharedLinkType } from '@app/infra/entities'; createAlbum,
import { INestApplication } from '@nestjs/common'; deleteUser,
import { errorStub, userDto, uuidStub } from '@test/fixtures'; } from '@immich/sdk';
import { DateTime } from 'luxon'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { api } from '../../client'; import { beforeAll, describe, expect, it } from 'vitest';
import { testApp } from '../utils';
describe(`${SharedLinkController.name} (e2e)`, () => { describe('/shared-link', () => {
let server: any;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset1: AssetResponseDto; let asset1: AssetResponseDto;
let asset2: AssetResponseDto; let asset2: AssetResponseDto;
@@ -30,97 +30,96 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
let linkWithAssets: SharedLinkResponseDto; let linkWithAssets: SharedLinkResponseDto;
let linkWithMetadata: SharedLinkResponseDto; let linkWithMetadata: SharedLinkResponseDto;
let linkWithoutMetadata: SharedLinkResponseDto; let linkWithoutMetadata: SharedLinkResponseDto;
let app: INestApplication<any>;
beforeAll(async () => { beforeAll(async () => {
app = await testApp.create(); apiUtils.setup();
server = app.getHttpServer(); await dbUtils.reset();
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
await testApp.reset(); admin = await apiUtils.adminSetup();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
[user1, user2] = await Promise.all([ [user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1), apiUtils.userSetup(admin.accessToken, createUserDto.user1),
api.authApi.login(server, userDto.user2), apiUtils.userSetup(admin.accessToken, createUserDto.user2),
]); ]);
[asset1, asset2] = await Promise.all([ [asset1, asset2] = await Promise.all([
api.assetApi.create(server, user1.accessToken), apiUtils.createAsset(user1.accessToken),
api.assetApi.create(server, user1.accessToken), apiUtils.createAsset(user1.accessToken),
]); ]);
await assetRepository.upsertExif({
assetId: asset1.id,
longitude: -108.400968333333,
latitude: 39.115,
orientation: '1',
dateTimeOriginal: DateTime.fromISO('2022-01-10T19:15:44.310Z').toJSDate(),
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
});
[album, deletedAlbum, metadataAlbum] = await Promise.all([ [album, deletedAlbum, metadataAlbum] = await Promise.all([
api.albumApi.create(server, user1.accessToken, { albumName: 'album' }), createAlbum(
api.albumApi.create(server, user2.accessToken, { albumName: 'deleted album' }), { createAlbumDto: { albumName: 'album' } },
api.albumApi.create(server, user1.accessToken, { albumName: 'metadata album', assetIds: [asset1.id] }), { headers: asBearerAuth(user1.accessToken) }
),
createAlbum(
{ createAlbumDto: { albumName: 'deleted album' } },
{ headers: asBearerAuth(user2.accessToken) }
),
createAlbum(
{
createAlbumDto: {
albumName: 'metadata album',
assetIds: [asset1.id],
},
},
{ headers: asBearerAuth(user1.accessToken) }
),
]); ]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] = [
await Promise.all([ linkWithDeletedAlbum,
api.sharedLinkApi.create(server, user2.accessToken, { linkWithAlbum,
type: SharedLinkType.ALBUM, linkWithAssets,
albumId: deletedAlbum.id, linkWithPassword,
}), linkWithMetadata,
api.sharedLinkApi.create(server, user1.accessToken, { linkWithoutMetadata,
type: SharedLinkType.ALBUM, ] = await Promise.all([
albumId: album.id, apiUtils.createSharedLink(user2.accessToken, {
}), type: SharedLinkType.Album,
api.sharedLinkApi.create(server, user1.accessToken, { albumId: deletedAlbum.id,
type: SharedLinkType.INDIVIDUAL, }),
assetIds: [asset1.id], apiUtils.createSharedLink(user1.accessToken, {
}), type: SharedLinkType.Album,
api.sharedLinkApi.create(server, user1.accessToken, { albumId: album.id,
type: SharedLinkType.ALBUM, }),
albumId: album.id, apiUtils.createSharedLink(user1.accessToken, {
password: 'foo', type: SharedLinkType.Individual,
}), assetIds: [asset1.id],
api.sharedLinkApi.create(server, user1.accessToken, { }),
type: SharedLinkType.ALBUM, apiUtils.createSharedLink(user1.accessToken, {
albumId: metadataAlbum.id, type: SharedLinkType.Album,
showMetadata: true, albumId: album.id,
}), password: 'foo',
api.sharedLinkApi.create(server, user1.accessToken, { }),
type: SharedLinkType.ALBUM, apiUtils.createSharedLink(user1.accessToken, {
albumId: metadataAlbum.id, type: SharedLinkType.Album,
showMetadata: false, albumId: metadataAlbum.id,
}), showMetadata: true,
]); }),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
await api.userApi.delete(server, admin.accessToken, user2.userId); await deleteUser(
}); { id: user2.userId },
{ headers: asBearerAuth(admin.accessToken) }
afterAll(async () => { );
await testApp.teardown();
}); });
describe('GET /shared-link', () => { describe('GET /shared-link', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).get('/shared-link'); const { status, body } = await request(app).get('/shared-link');
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should get all shared links created by user', async () => { it('should get all shared links created by user', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/shared-link') .get('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
@@ -133,12 +132,12 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
expect.objectContaining({ id: linkWithPassword.id }), expect.objectContaining({ id: linkWithPassword.id }),
expect.objectContaining({ id: linkWithMetadata.id }), expect.objectContaining({ id: linkWithMetadata.id }),
expect.objectContaining({ id: linkWithoutMetadata.id }), expect.objectContaining({ id: linkWithoutMetadata.id }),
]), ])
); );
}); });
it('should not get shared links created by other users', async () => { it('should not get shared links created by other users', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/shared-link') .get('/shared-link')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -149,7 +148,7 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('GET /shared-link/me', () => { describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => { it('should not require admin authentication', async () => {
const { status } = await request(server) const { status } = await request(app)
.get('/shared-link/me') .get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -157,52 +156,66 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
}); });
it('should get data for correct shared link', async () => { it('should get data for correct shared link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithAlbum.key }); const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithAlbum.key });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
expect.objectContaining({ expect.objectContaining({
album, album,
userId: user1.userId, userId: user1.userId,
type: SharedLinkType.ALBUM, type: SharedLinkType.Album,
}), })
); );
}); });
it('should return unauthorized for incorrect shared link', async () => { it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/shared-link/me') .get('/shared-link/me')
.query({ key: linkWithAlbum.key + 'foo' }); .query({ key: linkWithAlbum.key + 'foo' });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey); expect(body).toEqual(errorDto.invalidShareKey);
}); });
it('should return unauthorized if target has been soft deleted', async () => { it('should return unauthorized if target has been soft deleted', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key }); const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithDeletedAlbum.key });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey); expect(body).toEqual(errorDto.invalidShareKey);
}); });
it('should return unauthorized for password protected link', async () => { it('should return unauthorized for password protected link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithPassword.key }); const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithPassword.key });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidSharePassword); expect(body).toEqual(errorDto.invalidSharePassword);
}); });
it('should get data for correct password protected link', async () => { it('should get data for correct password protected link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get('/shared-link/me') .get('/shared-link/me')
.query({ key: linkWithPassword.key, password: 'foo' }); .query({ key: linkWithPassword.key, password: 'foo' });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM })); expect(body).toEqual(
expect.objectContaining({
album,
userId: user1.userId,
type: SharedLinkType.Album,
})
);
}); });
it('should return metadata for album shared link', async () => { it('should return metadata for album shared link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithMetadata.key }); const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithMetadata.key });
expect(status).toBe(200); expect(status).toBe(200);
expect(body.assets).toHaveLength(1); expect(body.assets).toHaveLength(1);
@@ -211,22 +224,16 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
originalFileName: 'example', originalFileName: 'example',
localDateTime: expect.any(String), localDateTime: expect.any(String),
fileCreatedAt: expect.any(String), fileCreatedAt: expect.any(String),
exifInfo: expect.objectContaining({ exifInfo: expect.any(Object),
longitude: -108.400968333333, })
latitude: 39.115,
orientation: '1',
dateTimeOriginal: expect.any(String),
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
}),
}),
); );
expect(body.album).toBeDefined(); expect(body.album).toBeDefined();
}); });
it('should not return metadata for album shared link without metadata', async () => { it('should not return metadata for album shared link without metadata', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithoutMetadata.key }); const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithoutMetadata.key });
expect(status).toBe(200); expect(status).toBe(200);
expect(body.assets).toHaveLength(1); expect(body.assets).toHaveLength(1);
@@ -242,127 +249,150 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('GET /shared-link/:id', () => { describe('GET /shared-link/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).get(`/shared-link/${linkWithAlbum.id}`); const { status, body } = await request(app).get(
`/shared-link/${linkWithAlbum.id}`
);
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should get shared link by id', async () => { it('should get shared link by id', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get(`/shared-link/${linkWithAlbum.id}`) .get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM })); expect(body).toEqual(
expect.objectContaining({
album,
userId: user1.userId,
type: SharedLinkType.Album,
})
);
}); });
it('should not get shared link by id if user has not created the link or it does not exist', async () => { it('should not get shared link by id if user has not created the link or it does not exist', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.get(`/shared-link/${linkWithAlbum.id}`) .get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' })); expect(body).toEqual(
expect.objectContaining({ message: 'Shared link not found' })
);
}); });
}); });
describe('POST /shared-link', () => { describe('POST /shared-link', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound }); .send({ type: SharedLinkType.Album, albumId: uuidDto.notFound });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require a type and the correspondent asset/album id', async () => { it('should require a type and the correspondent asset/album id', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
it('should require an asset/album id', async () => { it('should require an asset/album id', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM }); .send({ type: SharedLinkType.Album });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' })); expect(body).toEqual(
expect.objectContaining({ message: 'Invalid albumId' })
);
}); });
it('should require a valid asset id', async () => { it('should require a valid asset id', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound }); .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' })); expect(body).toEqual(
expect.objectContaining({ message: 'Invalid assetIds' })
);
}); });
it('should create a shared link', async () => { it('should create a shared link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.post('/shared-link') .post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM, albumId: album.id }); .send({ type: SharedLinkType.Album, albumId: album.id });
expect(status).toBe(201); expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId })); expect(body).toEqual(
expect.objectContaining({
type: SharedLinkType.Album,
userId: user1.userId,
})
);
}); });
}); });
describe('PATCH /shared-link/:id', () => { describe('PATCH /shared-link/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/shared-link/${linkWithAlbum.id}`) .patch(`/shared-link/${linkWithAlbum.id}`)
.send({ description: 'foo' }); .send({ description: 'foo' });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should fail if invalid link', async () => { it('should fail if invalid link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/shared-link/${uuidStub.notFound}`) .patch(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' }); .send({ description: 'foo' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
it('should update shared link', async () => { it('should update shared link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.patch(`/shared-link/${linkWithAlbum.id}`) .patch(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' }); .send({ description: 'foo' });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId, description: 'foo' }), expect.objectContaining({
type: SharedLinkType.Album,
userId: user1.userId,
description: 'foo',
})
); );
}); });
}); });
describe('PUT /shared-link/:id/assets', () => { describe('PUT /shared-link/:id/assets', () => {
it('should not add assets to shared link (album)', async () => { it('should not add assets to shared link (album)', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/shared-link/${linkWithAlbum.id}/assets`) .put(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Invalid shared link type')); expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
}); });
it('should add an assets to a shared link (individual)', async () => { it('should add an assets to a shared link (individual)', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.put(`/shared-link/${linkWithAssets.id}/assets`) .put(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
@@ -374,17 +404,17 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('DELETE /shared-link/:id/assets', () => { describe('DELETE /shared-link/:id/assets', () => {
it('should not remove assets from a shared link (album)', async () => { it('should not remove assets from a shared link (album)', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/shared-link/${linkWithAlbum.id}/assets`) .delete(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Invalid shared link type')); expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
}); });
it('should remove assets from a shared link (individual)', async () => { it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/shared-link/${linkWithAssets.id}/assets`) .delete(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] }); .send({ assetIds: [asset2.id] });
@@ -396,23 +426,25 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
describe('DELETE /shared-link/:id', () => { describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/shared-link/${linkWithAlbum.id}`); const { status, body } = await request(app).delete(
`/shared-link/${linkWithAlbum.id}`
);
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should fail if invalid link', async () => { it('should fail if invalid link', async () => {
const { status, body } = await request(server) const { status, body } = await request(app)
.delete(`/shared-link/${uuidStub.notFound}`) .delete(`/shared-link/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
it('should delete a shared link', async () => { it('should delete a shared link', async () => {
const { status } = await request(server) const { status } = await request(app)
.delete(`/shared-link/${linkWithAlbum.id}`) .delete(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
+51 -66
View File
@@ -1,26 +1,31 @@
import { import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk';
LoginResponseDto,
UserResponseDto,
createUser,
deleteUser,
getUserById,
} from '@immich/sdk';
import { createUserDto, userDto } from 'src/fixtures'; import { createUserDto, userDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
describe('/server-info', () => { describe('/server-info', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let deletedUser: LoginResponseDto;
let userToDelete: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => { beforeAll(async () => {
apiUtils.setup(); apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset(); await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false }); admin = await apiUtils.adminSetup({ onboarding: false });
[deletedUser, nonAdmin, userToDelete] = await Promise.all([
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
await deleteUser(
{ id: deletedUser.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
}); });
describe('GET /user', () => { describe('GET /user', () => {
@@ -30,60 +35,54 @@ describe('/server-info', () => {
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should start with the admin', async () => { it('should get users', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/user') .get('/user')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200); expect(status).toEqual(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(4);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
);
}); });
it('should hide deleted users', async () => { it('should hide deleted users', async () => {
const user1 = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
await deleteUser(
{ id: user1.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/user`) .get(`/user`)
.query({ isAll: true }) .query({ isAll: true })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(3);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
);
}); });
it('should include deleted users', async () => { it('should include deleted users', async () => {
const user1 = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
await deleteUser(
{ id: user1.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/user`) .get(`/user`)
.query({ isAll: false }) .query({ isAll: false })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(2); expect(body).toHaveLength(4);
expect(body[0]).toMatchObject({ expect(body).toEqual(
id: user1.userId, expect.arrayContaining([
email: 'user1@immich.cloud', expect.objectContaining({ email: 'admin@immich.cloud' }),
deletedAt: expect.any(String), expect.objectContaining({ email: 'user1@immich.cloud' }),
}); expect.objectContaining({ email: 'user2@immich.cloud' }),
expect(body[1]).toMatchObject({ expect.objectContaining({ email: 'user3@immich.cloud' }),
id: admin.userId, ])
email: 'admin@immich.cloud', );
});
}); });
}); });
@@ -149,13 +148,13 @@ describe('/server-info', () => {
.post(`/user`) .post(`/user`)
.send({ .send({
isAdmin: true, isAdmin: true,
email: 'user1@immich.cloud', email: 'user4@immich.cloud',
password: 'Password123', password: 'password123',
name: 'Immich', name: 'Immich',
}) })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({ expect(body).toMatchObject({
email: 'user1@immich.cloud', email: 'user4@immich.cloud',
isAdmin: false, isAdmin: false,
shouldChangePassword: true, shouldChangePassword: true,
}); });
@@ -181,18 +180,9 @@ describe('/server-info', () => {
}); });
describe('DELETE /user/:id', () => { describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await createUser(
{ createUserDto: createUserDto.user1 },
{ headers: asBearerAuth(admin.accessToken) }
);
});
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).delete( const { status, body } = await request(app).delete(
`/user/${userToDelete.id}` `/user/${userToDelete.userId}`
); );
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@@ -200,12 +190,12 @@ describe('/server-info', () => {
it('should delete user', async () => { it('should delete user', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/user/${userToDelete.id}`) .delete(`/user/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toMatchObject({
...userToDelete, id: userToDelete.userId,
updatedAt: expect.any(String), updatedAt: expect.any(String),
deletedAt: expect.any(String), deletedAt: expect.any(String),
}); });
@@ -231,14 +221,9 @@ describe('/server-info', () => {
} }
it('should not allow a non-admin to become an admin', async () => { it('should not allow a non-admin to become an admin', async () => {
const user = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/user`) .put(`/user`)
.send({ isAdmin: true, id: user.userId }) .send({ isAdmin: true, id: nonAdmin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
+79 -5
View File
@@ -1,24 +1,33 @@
import { import {
AssetResponseDto, AssetResponseDto,
CreateAlbumDto,
CreateAssetDto, CreateAssetDto,
CreateUserDto, CreateUserDto,
LoginResponseDto, PersonUpdateDto,
SharedLinkCreateDto,
createAlbum,
createApiKey, createApiKey,
createPerson,
createSharedLink,
createUser, createUser,
defaults, defaults,
login, login,
setAdminOnboarding, setAdminOnboarding,
signUpAdmin, signUpAdmin,
updatePerson,
} from '@immich/sdk'; } from '@immich/sdk';
import { BrowserContext } from '@playwright/test'; import { BrowserContext } from '@playwright/test';
import { spawn } from 'child_process'; import { exec, spawn } from 'child_process';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises'; import { access } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { promisify } from 'node:util';
import pg from 'pg'; import pg from 'pg';
import { loginDto, signupDto } from 'src/fixtures'; import { loginDto, signupDto } from 'src/fixtures';
import request from 'supertest'; import request from 'supertest';
const execPromise = promisify(exec);
export const app = 'http://127.0.0.1:2283/api'; export const app = 'http://127.0.0.1:2283/api';
const directoryExists = (directory: string) => const directoryExists = (directory: string) =>
@@ -29,6 +38,9 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets // TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`); export const testAssetDir = path.resolve(`./../server/test/assets/`);
const serverContainerName = 'immich-e2e-server';
const uploadMediaDir = '/usr/src/app/upload/upload';
if (!(await directoryExists(`${testAssetDir}/albums`))) { if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error( throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing` `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
@@ -44,8 +56,45 @@ export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
let client: pg.Client | null = null; let client: pg.Client | null = null;
export const dbUtils = { export const fileUtils = {
reset: async () => { reset: async () => {
await execPromise(
`docker exec -i "${serverContainerName}" rm -R "${uploadMediaDir}"`
);
},
};
export const dbUtils = {
createFace: async ({
assetId,
personId,
}: {
assetId: string;
personId: string;
}) => {
if (!client) {
return;
}
const vector = Array.from({ length: 512 }, Math.random);
const embedding = `[${vector.join(',')}]`;
await client.query(
'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)',
[assetId, personId, embedding]
);
},
setPersonThumbnail: async (personId: string) => {
if (!client) {
return;
}
await client.query(
`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`,
[personId]
);
},
reset: async (tables?: string[]) => {
try { try {
if (!client) { if (!client) {
client = new pg.Client( client = new pg.Client(
@@ -54,14 +103,20 @@ export const dbUtils = {
await client.connect(); await client.connect();
} }
for (const table of [ tables = tables || [
'shared_links',
'person',
'albums', 'albums',
'assets', 'assets',
'asset_faces',
'activity',
'api_keys', 'api_keys',
'user_token', 'user_token',
'users', 'users',
'system_metadata', 'system_metadata',
]) { ];
for (const table of tables) {
await client.query(`DELETE FROM ${table} CASCADE;`); await client.query(`DELETE FROM ${table} CASCADE;`);
} }
} catch (error) { } catch (error) {
@@ -144,6 +199,11 @@ export const apiUtils = {
{ headers: asBearerAuth(accessToken) } { headers: asBearerAuth(accessToken) }
); );
}, },
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum(
{ createAlbumDto: dto },
{ headers: asBearerAuth(accessToken) }
),
createAsset: async ( createAsset: async (
accessToken: string, accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'> dto?: Omit<CreateAssetDto, 'assetData'>
@@ -165,6 +225,20 @@ export const apiUtils = {
return body as AssetResponseDto; return body as AssetResponseDto;
}, },
createPerson: async (accessToken: string, dto: PersonUpdateDto) => {
// TODO fix createPerson to accept a body
const { id } = await createPerson({ headers: asBearerAuth(accessToken) });
await dbUtils.setPersonThumbnail(id);
return updatePerson(
{ id, personUpdateDto: dto },
{ headers: asBearerAuth(accessToken) }
);
},
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
createSharedLink(
{ sharedLinkCreateDto: dto },
{ headers: asBearerAuth(accessToken) }
),
}; };
export const cliUtils = { export const cliUtils = {
-1
View File
@@ -1 +0,0 @@
from .ann import Ann, is_available
+1 -2
View File
@@ -32,8 +32,7 @@ T = TypeVar("T", covariant=True)
class Newable(Protocol[T]): class Newable(Protocol[T]):
def new(self) -> None: def new(self) -> None: ...
...
class _Singleton(type, Newable[T]): class _Singleton(type, Newable[T]):
+13 -53
View File
@@ -1,18 +1,16 @@
from __future__ import annotations from __future__ import annotations
import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from typing import Any from typing import Any
import onnx
import onnxruntime as ort import onnxruntime as ort
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from onnx.shape_inference import infer_shapes
from onnx.tools.update_model_dims import update_inputs_outputs_dims
import ann.ann import ann.ann
from app.models.constants import STATIC_INPUT_PROVIDERS, SUPPORTED_PROVIDERS from app.models.constants import SUPPORTED_PROVIDERS
from ..config import get_cache_dir, get_hf_model_name, log, settings from ..config import get_cache_dir, get_hf_model_name, log, settings
from ..schemas import ModelRuntime, ModelType from ..schemas import ModelRuntime, ModelType
@@ -113,63 +111,25 @@ class InferenceModel(ABC):
) )
model_path = onnx_path model_path = onnx_path
if any(provider in STATIC_INPUT_PROVIDERS for provider in self.providers):
static_path = model_path.parent / "static_1" / "model.onnx"
static_path.parent.mkdir(parents=True, exist_ok=True)
if not static_path.is_file():
self._convert_to_static(model_path, static_path)
model_path = static_path
match model_path.suffix: match model_path.suffix:
case ".armnn": case ".armnn":
session = AnnSession(model_path) session = AnnSession(model_path)
case ".onnx": case ".onnx":
session = ort.InferenceSession( cwd = os.getcwd()
model_path.as_posix(), try:
sess_options=self.sess_options, os.chdir(model_path.parent)
providers=self.providers, session = ort.InferenceSession(
provider_options=self.provider_options, model_path.as_posix(),
) sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
finally:
os.chdir(cwd)
case _: case _:
raise ValueError(f"Unsupported model file type: {model_path.suffix}") raise ValueError(f"Unsupported model file type: {model_path.suffix}")
return session return session
def _convert_to_static(self, source_path: Path, target_path: Path) -> None:
inferred = infer_shapes(onnx.load(source_path))
inputs = self._get_static_dims(inferred.graph.input)
outputs = self._get_static_dims(inferred.graph.output)
# check_model gets called in update_inputs_outputs_dims and doesn't work for large models
check_model = onnx.checker.check_model
try:
def check_model_stub(*args: Any, **kwargs: Any) -> None:
pass
onnx.checker.check_model = check_model_stub
updated_model = update_inputs_outputs_dims(inferred, inputs, outputs)
finally:
onnx.checker.check_model = check_model
onnx.save(
updated_model,
target_path,
save_as_external_data=True,
all_tensors_to_one_file=False,
size_threshold=1048576,
)
def _get_static_dims(self, graph_io: Any, dim_size: int = 1) -> dict[str, list[int]]:
return {
field.name: [
d.dim_value if d.HasField("dim_value") else dim_size
for shape in field.type.ListFields()
if (dim := shape[1].shape.dim)
for d in dim
]
for field in graph_io
}
@property @property
def model_type(self) -> ModelType: def model_type(self) -> ModelType:
return self._model_type return self._model_type
-3
View File
@@ -54,9 +54,6 @@ _INSIGHTFACE_MODELS = {
SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"] SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"]
STATIC_INPUT_PROVIDERS = ["OpenVINOExecutionProvider"]
def is_openclip(model_name: str) -> bool: def is_openclip(model_name: str) -> bool:
return clean_name(model_name) in _OPENCLIP_MODELS return clean_name(model_name) in _OPENCLIP_MODELS
+24 -2
View File
@@ -1,4 +1,5 @@
import json import json
import os
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from random import randint from random import randint
@@ -237,12 +238,12 @@ class TestBase:
mock_model_path.is_file.return_value = True mock_model_path.is_file.return_value = True
mock_model_path.suffix = ".armnn" mock_model_path.suffix = ".armnn"
mock_model_path.with_suffix.return_value = mock_model_path mock_model_path.with_suffix.return_value = mock_model_path
mock_session = mocker.patch("app.models.base.AnnSession") mock_ann = mocker.patch("app.models.base.AnnSession")
encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_model_path) encoder._make_session(mock_model_path)
mock_session.assert_called_once() mock_ann.assert_called_once()
def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None: def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None:
mock_armnn_path = mocker.Mock() mock_armnn_path = mocker.Mock()
@@ -256,6 +257,7 @@ class TestBase:
mock_ann = mocker.patch("app.models.base.AnnSession") mock_ann = mocker.patch("app.models.base.AnnSession")
mock_ort = mocker.patch("app.models.base.ort.InferenceSession") mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
mocker.patch("app.models.base.os.chdir")
encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_armnn_path) encoder._make_session(mock_armnn_path)
@@ -278,6 +280,26 @@ class TestBase:
mock_ann.assert_not_called() mock_ann.assert_not_called()
mock_ort.assert_not_called() mock_ort.assert_not_called()
def test_make_session_changes_cwd(self, mocker: MockerFixture) -> None:
mock_model_path = mocker.Mock()
mock_model_path.is_file.return_value = True
mock_model_path.suffix = ".onnx"
mock_model_path.parent = "model_parent"
mock_model_path.with_suffix.return_value = mock_model_path
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
mock_chdir = mocker.patch("app.models.base.os.chdir")
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_model_path)
mock_chdir.assert_has_calls(
[
mock.call(mock_model_path.parent),
mock.call(os.getcwd()),
]
)
mock_ort.assert_called_once()
def test_download(self, mocker: MockerFixture) -> None: def test_download(self, mocker: MockerFixture) -> None:
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download") mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")
+188 -188
View File
@@ -64,33 +64,33 @@ trio = ["trio (>=0.23)"]
[[package]] [[package]]
name = "black" name = "black"
version = "24.1.1" version = "24.2.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"}, {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"},
{file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"}, {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"},
{file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"}, {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"},
{file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"}, {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"},
{file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"}, {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"},
{file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"}, {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"},
{file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"}, {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"},
{file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"}, {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"},
{file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"}, {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"},
{file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"}, {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"},
{file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"}, {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"},
{file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"}, {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"},
{file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"}, {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"},
{file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"}, {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"},
{file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"}, {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"},
{file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"}, {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"},
{file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"}, {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"},
{file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"}, {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"},
{file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"}, {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"},
{file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"}, {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"},
{file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"}, {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"},
{file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"}, {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"},
] ]
[package.dependencies] [package.dependencies]
@@ -2101,61 +2101,61 @@ numpy = [
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.9.13" version = "3.9.14"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "orjson-3.9.13-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fa6b67f8bef277c2a4aadd548d58796854e7d760964126c3209b19bccc6a74f1"}, {file = "orjson-3.9.14-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:793f6c9448ab6eb7d4974b4dde3f230345c08ca6c7995330fbceeb43a5c8aa5e"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b812417199eeb169c25f67815cfb66fd8de7ff098bf57d065e8c1943a7ba5c8f"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bc7928d161840096adc956703494b5c0193ede887346f028216cac0af87500"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ccd5bd222e5041069ad9d9868ab59e6dbc53ecde8d8c82b919954fbba43b46b"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58b36f54da759602d8e2f7dad958752d453dfe2c7122767bc7f765e17dc59959"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaaf80957c38e9d3f796f355a80fad945e72cd745e6b64c210e635b7043b673e"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:abcda41ecdc950399c05eff761c3de91485d9a70d8227cb599ad3a66afe93bcc"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60da7316131185d0110a1848e9ad15311e6c8938ee0b5be8cbd7261e1d80ee8f"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df76ecd17b1b3627bddfd689faaf206380a1a38cc9f6c4075bd884eaedcf46c2"},
{file = "orjson-3.9.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b98cd948372f0eb219bc309dee4633db1278687161e3280d9e693b6076951d2"}, {file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d450a8e0656efb5d0fcb062157b918ab02dcca73278975b4ee9ea49e2fcf5bd5"},
{file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3869d65561f10071d3e7f35ae58fd377056f67d7aaed5222f318390c3ad30339"}, {file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:95c03137b0cf66517c8baa65770507a756d3a89489d8ecf864ea92348e1beabe"},
{file = "orjson-3.9.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43fd6036b16bb6742d03dae62f7bdf8214d06dea47e4353cde7e2bd1358d186f"}, {file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20837e10835c98973673406d6798e10f821e7744520633811a5a3d809762d8cc"},
{file = "orjson-3.9.13-cp310-none-win32.whl", hash = "sha256:0d3ba9d88e20765335260d7b25547d7c571eee2b698200f97afa7d8c7cd668fc"}, {file = "orjson-3.9.14-cp310-none-win32.whl", hash = "sha256:1f7b6f3ef10ae8e3558abb729873d033dbb5843507c66b1c0767e32502ba96bb"},
{file = "orjson-3.9.13-cp310-none-win_amd64.whl", hash = "sha256:6e47153db080f5e87e8ba638f1a8b18995eede6b0abb93964d58cf11bcea362f"}, {file = "orjson-3.9.14-cp310-none-win_amd64.whl", hash = "sha256:ea890e6dc1711aeec0a33b8520e395c2f3d59ead5b4351a788e06bf95fc7ba81"},
{file = "orjson-3.9.13-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4584e8eb727bc431baaf1bf97e35a1d8a0109c924ec847395673dfd5f4ef6d6f"}, {file = "orjson-3.9.14-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c19009ff37f033c70acd04b636380379499dac2cba27ae7dfc24f304deabbc81"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f37f0cdd026ef777a4336e599d8194c8357fc14760c2a5ddcfdf1965d45504b"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19cdea0664aec0b7f385be84986d4defd3334e9c3c799407686ee1c26f7b8251"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d714595d81efab11b42bccd119977d94b25d12d3a806851ff6bfd286a4bce960"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:135d518f73787ce323b1a5e21fb854fe22258d7a8ae562b81a49d6c7f826f2a3"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9171e8e1a1f221953e38e84ae0abffe8759002fd8968106ee379febbb5358b33"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2cf1d0557c61c75e18cf7d69fb689b77896e95553e212c0cc64cf2087944b84"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ab9dbdec3f13f3ea6f937564ce21651844cfbf2725099f2f490426acf683c23"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7c11667421df2d8b18b021223505dcc3ee51be518d54e4dc49161ac88ac2b87"},
{file = "orjson-3.9.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:811ac076855e33e931549340288e0761873baf29276ad00f221709933c644330"}, {file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eefc41ba42e75ed88bc396d8fe997beb20477f3e7efa000cd7a47eda452fbb2"},
{file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:860d0f5b42d0c0afd73fa4177709f6e1b966ba691fcd72175affa902052a81d6"}, {file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:917311d6a64d1c327c0dfda1e41f3966a7fb72b11ca7aa2e7a68fcccc7db35d9"},
{file = "orjson-3.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:838b898e8c1f26eb6b8d81b180981273f6f5110c76c22c384979aca854194f1b"}, {file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4dc1c132259b38d12c6587d190cd09cd76e3b5273ce71fe1372437b4cbc65f6f"},
{file = "orjson-3.9.13-cp311-none-win32.whl", hash = "sha256:d3222db9df629ef3c3673124f2e05fb72bc4a320c117e953fec0d69dde82e36d"}, {file = "orjson-3.9.14-cp311-none-win32.whl", hash = "sha256:6f39a10408478f4c05736a74da63727a1ae0e83e3533d07b19443400fe8591ca"},
{file = "orjson-3.9.13-cp311-none-win_amd64.whl", hash = "sha256:978117122ca4cc59b28af5322253017f6c5fc03dbdda78c7f4b94ae984c8dd43"}, {file = "orjson-3.9.14-cp311-none-win_amd64.whl", hash = "sha256:26280a7fcb62d8257f634c16acebc3bec626454f9ab13558bbf7883b9140760e"},
{file = "orjson-3.9.13-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:031df1026c7ea8303332d78711f180231e3ae8b564271fb748a03926587c5546"}, {file = "orjson-3.9.14-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:08e722a8d06b13b67a51f247a24938d1a94b4b3862e40e0eef3b2e98c99cd04c"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd9a2101d04e85086ea6198786a3f016e45475f800712e6833e14bf9ce2832f"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2591faa0c031cf3f57e5bce1461cfbd6160f3f66b5a72609a130924917cb07d"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:446d9ad04204e79229ae19502daeea56479e55cbc32634655d886f5a39e91b44"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2450d87dd7b4f277f4c5598faa8b49a0c197b91186c47a2c0b88e15531e4e3e"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b57c0954a9fdd2b05b9cec0f5a12a0bdce5bf021a5b3b09323041613972481ab"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90903d2908158a2c9077a06f11e27545de610af690fb178fd3ba6b32492d4d1c"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:266e55c83f81248f63cc93d11c5e3a53df49a5d2598fa9e9db5f99837a802d5d"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce6f095eef0026eae76fc212f20f786011ecf482fc7df2f4c272a8ae6dd7b1ef"},
{file = "orjson-3.9.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31372ba3a9fe8ad118e7d22fba46bbc18e89039e3bfa89db7bc8c18ee722dca8"}, {file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:751250a31fef2bac05a2da2449aae7142075ea26139271f169af60456d8ad27a"},
{file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3b0c4da61f39899561e08e571f54472a09fa71717d9797928af558175ae5243"}, {file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a1af21160a38ee8be3f4fcf24ee4b99e6184cadc7f915d599f073f478a94d2c"},
{file = "orjson-3.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cc03a35bfc71c8ebf96ce49b82c2a7be6af4b3cd3ac34166fdb42ac510bbfff"}, {file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:449bf090b2aa4e019371d7511a6ea8a5a248139205c27d1834bb4b1e3c44d936"},
{file = "orjson-3.9.13-cp312-none-win_amd64.whl", hash = "sha256:49b7e3fe861cb246361825d1a238f2584ed8ea21e714bf6bb17cebb86772e61c"}, {file = "orjson-3.9.14-cp312-none-win_amd64.whl", hash = "sha256:a603161318ff699784943e71f53899983b7dee571b4dd07c336437c9c5a272b0"},
{file = "orjson-3.9.13-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:62e9a99879c4d5a04926ac2518a992134bfa00d546ea5a4cae4b9be454d35a22"}, {file = "orjson-3.9.14-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:814f288c011efdf8f115c5ebcc1ab94b11da64b207722917e0ceb42f52ef30a3"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d92a3e835a5100f1d5b566fff79217eab92223ca31900dba733902a182a35ab0"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88cafb100af68af3b9b29b5ccd09fdf7a48c63327916c8c923a94c336d38dd3"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23f21faf072ed3b60b5954686f98157e073f6a8068eaa58dbde83e87212eda84"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba3518b999f88882ade6686f1b71e207b52e23546e180499be5bbb63a2f9c6e6"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:828c502bb261588f7de897e06cb23c4b122997cb039d2014cb78e7dabe92ef0c"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978f416bbff9da8d2091e3cf011c92da68b13f2c453dcc2e8109099b2a19d234"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16946d095212a3dec552572c5d9bca7afa40f3116ad49695a397be07d529f1fa"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75fc593cf836f631153d0e21beaeb8d26e144445c73645889335c2247fcd71a0"},
{file = "orjson-3.9.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3deadd8dc0e9ff844b5b656fa30a48dbee1c3b332d8278302dd9637f6b09f627"}, {file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d1528db3c7554f9d6eeb09df23cb80dd5177ec56eeb55cc5318826928de506"},
{file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9b1b5adc5adf596c59dca57156b71ad301d73956f5bab4039b0e34dbf50b9fa0"}, {file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7183cc68ee2113b19b0b8714221e5e3b07b3ba10ca2bb108d78fd49cefaae101"},
{file = "orjson-3.9.13-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ddc089315d030c54f0f03fb38286e2667c05009a78d659f108a8efcfbdf2e585"}, {file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df3266d54246cb56b8bb17fa908660d2a0f2e3f63fbc32451ffc1b1505051d07"},
{file = "orjson-3.9.13-cp38-none-win32.whl", hash = "sha256:ae77275a28667d9c82d4522b681504642055efa0368d73108511647c6499b31c"}, {file = "orjson-3.9.14-cp38-none-win32.whl", hash = "sha256:7913079b029e1b3501854c9a78ad938ed40d61fe09bebab3c93e60ff1301b189"},
{file = "orjson-3.9.13-cp38-none-win_amd64.whl", hash = "sha256:730385fdb99a21fce9bb84bb7fcbda72c88626facd74956bda712834b480729d"}, {file = "orjson-3.9.14-cp38-none-win_amd64.whl", hash = "sha256:29512eb925b620e5da2fd7585814485c67cc6ba4fe739a0a700c50467a8a8065"},
{file = "orjson-3.9.13-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7e8e4a571d958910272af8d53a9cbe6599f9f5fd496a1bc51211183bb2072cbd"}, {file = "orjson-3.9.14-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bf597530544db27a8d76aced49cfc817ee9503e0a4ebf0109cd70331e7bbe0c"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfad553a36548262e7da0f3a7464270e13900b898800fb571a5d4b298c3f8356"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac650d49366fa41fe702e054cb560171a8634e2865537e91f09a8d05ea5b1d37"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d691c44604941945b00e0a13b19a7d9c1a19511abadf0080f373e98fdeb6b31"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:236230433a9a4968ab895140514c308fdf9f607cb8bee178a04372b771123860"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8c83718346de08d68b3cb1105c5d91e5fc39885d8610fdda16613d4e3941459"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3014ccbda9be0b1b5f8ea895121df7e6524496b3908f4397ff02e923bcd8f6dd"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ef57a53bfc2091a7cd50a640d9ae866bd7d92a5225a1bab6baa60ef62583f2"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac0c7eae7ad3a223bde690565442f8a3d620056bd01196f191af8be58a5248e1"},
{file = "orjson-3.9.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9156b96afa38db71344522f5517077eaedf62fcd2c9148392ff93d801128809c"}, {file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca33fdd0b38839b01912c57546d4f412ba7bfa0faf9bf7453432219aec2df07"},
{file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31fb66b41fb2c4c817d9610f0bc7d31345728d7b5295ac78b63603407432a2b2"}, {file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f75823cc1674a840a151e999a7dfa0d86c911150dd6f951d0736ee9d383bf415"},
{file = "orjson-3.9.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8a730bf07feacb0863974e67b206b7c503a62199de1cece2eb0d4c233ec29c11"}, {file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f52ac2eb49e99e7373f62e2a68428c6946cda52ce89aa8fe9f890c7278e2d3a"},
{file = "orjson-3.9.13-cp39-none-win32.whl", hash = "sha256:5ef58869f3399acbbe013518d8b374ee9558659eef14bca0984f67cb1fbd3c37"}, {file = "orjson-3.9.14-cp39-none-win32.whl", hash = "sha256:0572f174f50b673b7df78680fb52cd0087a8585a6d06d295a5f790568e1064c6"},
{file = "orjson-3.9.13-cp39-none-win_amd64.whl", hash = "sha256:9bcf56efdb83244cde070e82a69c0f03c47c235f0a5cb6c81d9da23af7fbaae4"}, {file = "orjson-3.9.14-cp39-none-win_amd64.whl", hash = "sha256:ab90c02cb264250b8a58cedcc72ed78a4a257d956c8d3c8bebe9751b818dfad8"},
{file = "orjson-3.9.13.tar.gz", hash = "sha256:fc6bc65b0cf524ee042e0bc2912b9206ef242edfba7426cf95763e4af01f527a"}, {file = "orjson-3.9.14.tar.gz", hash = "sha256:06fb40f8e49088ecaa02f1162581d39e2cf3fd9dbbfe411eb2284147c99bad79"},
] ]
[[package]] [[package]]
@@ -3096,121 +3096,121 @@ all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib"
[[package]] [[package]]
name = "tokenizers" name = "tokenizers"
version = "0.15.1" version = "0.15.2"
description = "" description = ""
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "tokenizers-0.15.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:32c9491dd1bcb33172c26b454dbd607276af959b9e78fa766e2694cafab3103c"}, {file = "tokenizers-0.15.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:52f6130c9cbf70544287575a985bf44ae1bda2da7e8c24e97716080593638012"},
{file = "tokenizers-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29a1b784b870a097e7768f8c20c2dd851e2c75dad3efdae69a79d3e7f1d614d5"}, {file = "tokenizers-0.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:054c1cc9c6d68f7ffa4e810b3d5131e0ba511b6e4be34157aa08ee54c2f8d9ee"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0049fbe648af04148b08cb211994ce8365ee628ce49724b56aaefd09a3007a78"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9b9b070fdad06e347563b88c278995735292ded1132f8657084989a4c84a6d5"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e84b3c235219e75e24de6b71e6073cd2c8d740b14d88e4c6d131b90134e3a338"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea621a7eef4b70e1f7a4e84dd989ae3f0eeb50fc8690254eacc08acb623e82f1"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8cc575769ea11d074308c6d71cb10b036cdaec941562c07fc7431d956c502f0e"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7fd9a5141634fa3aa8d6b7be362e6ae1b4cda60da81388fa533e0b552c98fd"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bf28f299c4158e6d0b5eaebddfd500c4973d947ffeaca8bcbe2e8c137dff0b"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44f2a832cd0825295f7179eaf173381dc45230f9227ec4b44378322d900447c9"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:506555f98361db9c74e1323a862d77dcd7d64c2058829a368bf4159d986e339f"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b9ec69247a23747669ec4b0ca10f8e3dfb3545d550258129bd62291aabe8605"},
{file = "tokenizers-0.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7061b0a28ade15906f5b2ec8c48d3bdd6e24eca6b427979af34954fbe31d5cef"}, {file = "tokenizers-0.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b6a4c78da863ff26dbd5ad9a8ecc33d8a8d97b535172601cf00aee9d7ce9ce"},
{file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ed5e35507b7a0e2aac3285c4f5e37d4ec5cfc0e5825b862b68a0aaf2757af52"}, {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5ab2a4d21dcf76af60e05af8063138849eb1d6553a0d059f6534357bce8ba364"},
{file = "tokenizers-0.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c9df9247df0de6509dd751b1c086e5f124b220133b5c883bb691cb6fb3d786f"}, {file = "tokenizers-0.15.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a47acfac7e511f6bbfcf2d3fb8c26979c780a91e06fb5b9a43831b2c0153d024"},
{file = "tokenizers-0.15.1-cp310-none-win32.whl", hash = "sha256:dd999af1b4848bef1b11d289f04edaf189c269d5e6afa7a95fa1058644c3f021"}, {file = "tokenizers-0.15.2-cp310-none-win32.whl", hash = "sha256:064ff87bb6acdbd693666de9a4b692add41308a2c0ec0770d6385737117215f2"},
{file = "tokenizers-0.15.1-cp310-none-win_amd64.whl", hash = "sha256:39d06a57f7c06940d602fad98702cf7024c4eee7f6b9fe76b9f2197d5a4cc7e2"}, {file = "tokenizers-0.15.2-cp310-none-win_amd64.whl", hash = "sha256:3b919afe4df7eb6ac7cafd2bd14fb507d3f408db7a68c43117f579c984a73843"},
{file = "tokenizers-0.15.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8ad034eb48bf728af06915e9294871f72fcc5254911eddec81d6df8dba1ce055"}, {file = "tokenizers-0.15.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:89cd1cb93e4b12ff39bb2d626ad77e35209de9309a71e4d3d4672667b4b256e7"},
{file = "tokenizers-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea9ede7c42f8fa90f31bfc40376fd91a7d83a4aa6ad38e6076de961d48585b26"}, {file = "tokenizers-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfed5c64e5be23d7ee0f0e98081a25c2a46b0b77ce99a4f0605b1ec43dd481fa"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b85d6fe1a20d903877aa0ef32ef6b96e81e0e48b71c206d6046ce16094de6970"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a907d76dcfda37023ba203ab4ceeb21bc5683436ebefbd895a0841fd52f6f6f2"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a7d44f656320137c7d643b9c7dcc1814763385de737fb98fd2643880910f597"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ea60479de6fc7b8ae756b4b097572372d7e4032e2521c1bbf3d90c90a99ff0"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd244bd0793cdacf27ee65ec3db88c21f5815460e8872bbeb32b040469d6774e"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48e2b9335be2bc0171df9281385c2ed06a15f5cf121c44094338306ab7b33f2c"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3f4a36e371b3cb1123adac8aeeeeab207ad32f15ed686d9d71686a093bb140"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:112a1dd436d2cc06e6ffdc0b06d55ac019a35a63afd26475205cb4b1bf0bfbff"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2921a53966afb29444da98d56a6ccbef23feb3b0c0f294b4e502370a0a64f25"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4620cca5c2817177ee8706f860364cc3a8845bc1e291aaf661fb899e5d1c45b0"},
{file = "tokenizers-0.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f49068cf51f49c231067f1a8c9fc075ff960573f6b2a956e8e1b0154fb638ea5"}, {file = "tokenizers-0.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccd73a82751c523b3fc31ff8194702e4af4db21dc20e55b30ecc2079c5d43cb7"},
{file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0ab1a22f20eaaab832ab3b00a0709ca44a0eb04721e580277579411b622c741c"}, {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:107089f135b4ae7817affe6264f8c7a5c5b4fd9a90f9439ed495f54fcea56fb4"},
{file = "tokenizers-0.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:671268f24b607c4adc6fa2b5b580fd4211b9f84b16bd7f46d62f8e5be0aa7ba4"}, {file = "tokenizers-0.15.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0ff110ecc57b7aa4a594396525a3451ad70988e517237fe91c540997c4e50e29"},
{file = "tokenizers-0.15.1-cp311-none-win32.whl", hash = "sha256:a4f03e33d2bf7df39c8894032aba599bf90f6f6378e683a19d28871f09bb07fc"}, {file = "tokenizers-0.15.2-cp311-none-win32.whl", hash = "sha256:6d76f00f5c32da36c61f41c58346a4fa7f0a61be02f4301fd30ad59834977cc3"},
{file = "tokenizers-0.15.1-cp311-none-win_amd64.whl", hash = "sha256:30f689537bcc7576d8bd4daeeaa2cb8f36446ba2f13f421b173e88f2d8289c4e"}, {file = "tokenizers-0.15.2-cp311-none-win_amd64.whl", hash = "sha256:cc90102ed17271cf0a1262babe5939e0134b3890345d11a19c3145184b706055"},
{file = "tokenizers-0.15.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f3a379dd0898a82ea3125e8f9c481373f73bffce6430d4315f0b6cd5547e409"}, {file = "tokenizers-0.15.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f86593c18d2e6248e72fb91c77d413a815153b8ea4e31f7cd443bdf28e467670"},
{file = "tokenizers-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d870ae58bba347d38ac3fc8b1f662f51e9c95272d776dd89f30035c83ee0a4f"}, {file = "tokenizers-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0774bccc6608eca23eb9d620196687c8b2360624619623cf4ba9dc9bd53e8b51"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d6d28e0143ec2e253a8a39e94bf1d24776dbe73804fa748675dbffff4a5cd6d8"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0222c5b7c9b26c0b4822a82f6a7011de0a9d3060e1da176f66274b70f846b98"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61ae9ac9f44e2da128ee35db69489883b522f7abe033733fa54eb2de30dac23d"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3835738be1de66624fff2f4f6f6684775da4e9c00bde053be7564cbf3545cc66"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8e322a47e29128300b3f7749a03c0ec2bce0a3dc8539ebff738d3f59e233542"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0143e7d9dcd811855c1ce1ab9bf5d96d29bf5e528fd6c7824d0465741e8c10fd"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:760334f475443bc13907b1a8e1cb0aeaf88aae489062546f9704dce6c498bfe2"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db35825f6d54215f6b6009a7ff3eedee0848c99a6271c870d2826fbbedf31a38"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b173753d4aca1e7d0d4cb52b5e3ffecfb0ca014e070e40391b6bb4c1d6af3f2"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f5e64b0389a2be47091d8cc53c87859783b837ea1a06edd9d8e04004df55a5c"},
{file = "tokenizers-0.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1f13d457c8f0ab17e32e787d03470067fe8a3b4d012e7cc57cb3264529f4a"}, {file = "tokenizers-0.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e0480c452217edd35eca56fafe2029fb4d368b7c0475f8dfa3c5c9c400a7456"},
{file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:425b46ceff4505f20191df54b50ac818055d9d55023d58ae32a5d895b6f15bb0"}, {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a33ab881c8fe70474980577e033d0bc9a27b7ab8272896e500708b212995d834"},
{file = "tokenizers-0.15.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:681ac6ba3b4fdaf868ead8971221a061f580961c386e9732ea54d46c7b72f286"}, {file = "tokenizers-0.15.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a308a607ca9de2c64c1b9ba79ec9a403969715a1b8ba5f998a676826f1a7039d"},
{file = "tokenizers-0.15.1-cp312-none-win32.whl", hash = "sha256:f2272656063ccfba2044df2115095223960d80525d208e7a32f6c01c351a6f4a"}, {file = "tokenizers-0.15.2-cp312-none-win32.whl", hash = "sha256:b8fcfa81bcb9447df582c5bc96a031e6df4da2a774b8080d4f02c0c16b42be0b"},
{file = "tokenizers-0.15.1-cp312-none-win_amd64.whl", hash = "sha256:9abe103203b1c6a2435d248d5ff4cceebcf46771bfbc4957a98a74da6ed37674"}, {file = "tokenizers-0.15.2-cp312-none-win_amd64.whl", hash = "sha256:38d7ab43c6825abfc0b661d95f39c7f8af2449364f01d331f3b51c94dcff7221"},
{file = "tokenizers-0.15.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2ce9ed5c8ef26b026a66110e3c7b73d93ec2d26a0b1d0ea55ddce61c0e5f446f"}, {file = "tokenizers-0.15.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:38bfb0204ff3246ca4d5e726e8cc8403bfc931090151e6eede54d0e0cf162ef0"},
{file = "tokenizers-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89b24d366137986c3647baac29ef902d2d5445003d11c30df52f1bd304689aeb"}, {file = "tokenizers-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c861d35e8286a53e06e9e28d030b5a05bcbf5ac9d7229e561e53c352a85b1fc"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0faebedd01b413ab777ca0ee85914ed8b031ea5762ab0ea60b707ce8b9be6842"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:936bf3842db5b2048eaa53dade907b1160f318e7c90c74bfab86f1e47720bdd6"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdbd9dfcdad4f3b95d801f768e143165165055c18e44ca79a8a26de889cd8e85"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:620beacc3373277700d0e27718aa8b25f7b383eb8001fba94ee00aeea1459d89"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97194324c12565b07e9993ca9aa813b939541185682e859fb45bb8d7d99b3193"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2735ecbbf37e52db4ea970e539fd2d450d213517b77745114f92867f3fc246eb"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485e43e2cc159580e0d83fc919ec3a45ae279097f634b1ffe371869ffda5802c"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:473c83c5e2359bb81b0b6fde870b41b2764fcdd36d997485e07e72cc3a62264a"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191d084d60e3589d6420caeb3f9966168269315f8ec7fbc3883122dc9d99759d"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968fa1fb3c27398b28a4eca1cbd1e19355c4d3a6007f7398d48826bbe3a0f728"},
{file = "tokenizers-0.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01c28cc8d7220634a75b14c53f4fc9d1b485f99a5a29306a999c115921de2897"}, {file = "tokenizers-0.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:865c60ae6eaebdde7da66191ee9b7db52e542ed8ee9d2c653b6d190a9351b980"},
{file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:325212027745d3f8d5d5006bb9e5409d674eb80a184f19873f4f83494e1fdd26"}, {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7c0d8b52664ab2d4a8d6686eb5effc68b78608a9008f086a122a7b2996befbab"},
{file = "tokenizers-0.15.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3c5573603c36ce12dbe318bcfb490a94cad2d250f34deb2f06cb6937957bbb71"}, {file = "tokenizers-0.15.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f33dfbdec3784093a9aebb3680d1f91336c56d86cc70ddf88708251da1fe9064"},
{file = "tokenizers-0.15.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:1441161adb6d71a15a630d5c1d8659d5ebe41b6b209586fbeea64738e58fcbb2"}, {file = "tokenizers-0.15.2-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:d44ba80988ff9424e33e0a49445072ac7029d8c0e1601ad25a0ca5f41ed0c1d6"},
{file = "tokenizers-0.15.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:382a8d0c31afcfb86571afbfefa37186df90865ce3f5b731842dab4460e53a38"}, {file = "tokenizers-0.15.2-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:dce74266919b892f82b1b86025a613956ea0ea62a4843d4c4237be2c5498ed3a"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e76959783e3f4ec73b3f3d24d4eec5aa9225f0bee565c48e77f806ed1e048f12"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0ef06b9707baeb98b316577acb04f4852239d856b93e9ec3a299622f6084e4be"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:401df223e5eb927c5961a0fc6b171818a2bba01fb36ef18c3e1b69b8cd80e591"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73e2e74bbb07910da0d37c326869f34113137b23eadad3fc00856e6b3d9930c"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52606c233c759561a16e81b2290a7738c3affac7a0b1f0a16fe58dc22e04c7d"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eeb12daf02a59e29f578a865f55d87cd103ce62bd8a3a5874f8fdeaa82e336b"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72c658bbe5a05ed8bc2ac5ad782385bfd743ffa4bc87d9b5026341e709c6f44"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ba9f6895af58487ca4f54e8a664a322f16c26bbb442effd01087eba391a719e"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25f5643a2f005c42f0737a326c6c6bdfedfdc9a994b10a1923d9c3e792e4d6a6"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccec77aa7150e38eec6878a493bf8c263ff1fa8a62404e16c6203c64c1f16a26"},
{file = "tokenizers-0.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5b6f633999d6b42466bbfe21be2e26ad1760b6f106967a591a41d8cbca980e"}, {file = "tokenizers-0.15.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f40604f5042ff210ba82743dda2b6aa3e55aa12df4e9f2378ee01a17e2855e"},
{file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ceb5c9ad11a015150b545c1a11210966a45b8c3d68a942e57cf8938c578a77ca"}, {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5645938a42d78c4885086767c70923abad047163d809c16da75d6b290cb30bbe"},
{file = "tokenizers-0.15.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bedd4ce0c4872db193444c395b11c7697260ce86a635ab6d48102d76be07d324"}, {file = "tokenizers-0.15.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:05a77cbfebe28a61ab5c3891f9939cc24798b63fa236d84e5f29f3a85a200c00"},
{file = "tokenizers-0.15.1-cp37-none-win32.whl", hash = "sha256:cd6caef6c14f5ed6d35f0ddb78eab8ca6306d0cd9870330bccff72ad014a6f42"}, {file = "tokenizers-0.15.2-cp37-none-win32.whl", hash = "sha256:361abdc068e8afe9c5b818769a48624687fb6aaed49636ee39bec4e95e1a215b"},
{file = "tokenizers-0.15.1-cp37-none-win_amd64.whl", hash = "sha256:d2bd7af78f58d75a55e5df61efae164ab9200c04b76025f9cc6eeb7aff3219c2"}, {file = "tokenizers-0.15.2-cp37-none-win_amd64.whl", hash = "sha256:7ef789f83eb0f9baeb4d09a86cd639c0a5518528f9992f38b28e819df397eb06"},
{file = "tokenizers-0.15.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:59b3ca6c02e0bd5704caee274978bd055de2dff2e2f39dadf536c21032dfd432"}, {file = "tokenizers-0.15.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4fe1f74a902bee74a3b25aff180fbfbf4f8b444ab37c4d496af7afd13a784ed2"},
{file = "tokenizers-0.15.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:48fe21b67c22583bed71933a025fd66b1f5cfae1baefa423c3d40379b5a6e74e"}, {file = "tokenizers-0.15.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c4b89038a684f40a6b15d6b09f49650ac64d951ad0f2a3ea9169687bbf2a8ba"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3d190254c66a20fb1efbdf035e6333c5e1f1c73b1f7bfad88f9c31908ac2c2c4"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d05a1b06f986d41aed5f2de464c003004b2df8aaf66f2b7628254bcbfb72a438"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef90c8f5abf17d48d6635f5fd92ad258acd1d0c2d920935c8bf261782cfe7c8"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:508711a108684111ec8af89d3a9e9e08755247eda27d0ba5e3c50e9da1600f6d"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fac011ef7da3357aa7eb19efeecf3d201ede9618f37ddedddc5eb809ea0963ca"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daa348f02d15160cb35439098ac96e3a53bacf35885072611cd9e5be7d333daa"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:574ec5b3e71d1feda6b0ecac0e0445875729b4899806efbe2b329909ec75cb50"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:494fdbe5932d3416de2a85fc2470b797e6f3226c12845cadf054dd906afd0442"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aca16c3c0637c051a59ea99c4253f16fbb43034fac849076a7e7913b2b9afd2d"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2d60f5246f4da9373f75ff18d64c69cbf60c3bca597290cea01059c336d2470"},
{file = "tokenizers-0.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6f238fc2bbfd3e12e8529980ec1624c7e5b69d4e959edb3d902f36974f725a"}, {file = "tokenizers-0.15.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93268e788825f52de4c7bdcb6ebc1fcd4a5442c02e730faa9b6b08f23ead0e24"},
{file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:587e11a26835b73c31867a728f32ca8a93c9ded4a6cd746516e68b9d51418431"}, {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6fc7083ab404019fc9acafe78662c192673c1e696bd598d16dc005bd663a5cf9"},
{file = "tokenizers-0.15.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6456e7ad397352775e2efdf68a9ec5d6524bbc4543e926eef428d36de627aed4"}, {file = "tokenizers-0.15.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e39b41e5531d6b2122a77532dbea60e171ef87a3820b5a3888daa847df4153"},
{file = "tokenizers-0.15.1-cp38-none-win32.whl", hash = "sha256:614f0da7dd73293214bd143e6221cafd3f7790d06b799f33a987e29d057ca658"}, {file = "tokenizers-0.15.2-cp38-none-win32.whl", hash = "sha256:06cd0487b1cbfabefb2cc52fbd6b1f8d4c37799bd6c6e1641281adaa6b2504a7"},
{file = "tokenizers-0.15.1-cp38-none-win_amd64.whl", hash = "sha256:a4fa0a20d9f69cc2bf1cfce41aa40588598e77ec1d6f56bf0eb99769969d1ede"}, {file = "tokenizers-0.15.2-cp38-none-win_amd64.whl", hash = "sha256:5179c271aa5de9c71712e31cb5a79e436ecd0d7532a408fa42a8dbfa4bc23fd9"},
{file = "tokenizers-0.15.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8d3f18a45e0cf03ce193d5900460dc2430eec4e14c786e5d79bddba7ea19034f"}, {file = "tokenizers-0.15.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82f8652a74cc107052328b87ea8b34291c0f55b96d8fb261b3880216a9f9e48e"},
{file = "tokenizers-0.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:38dbd6c38f88ad7d5dc5d70c764415d38fe3bcd99dc81638b572d093abc54170"}, {file = "tokenizers-0.15.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02458bee6f5f3139f1ebbb6d042b283af712c0981f5bc50edf771d6b762d5e4f"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:777286b1f7e52de92aa4af49fe31046cfd32885d1bbaae918fab3bba52794c33"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9a09cd26cca2e1c349f91aa665309ddb48d71636370749414fbf67bc83c5343"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d4d550a3862a47dd249892d03a025e32286eb73cbd6bc887fb8fb64bc97165"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158be8ea8554e5ed69acc1ce3fbb23a06060bd4bbb09029431ad6b9a466a7121"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eda68ce0344f35042ae89220b40a0007f721776b727806b5c95497b35714bb7"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ddba9a2b0c8c81633eca0bb2e1aa5b3a15362b1277f1ae64176d0f6eba78ab1"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cd33d15f7a3a784c3b665cfe807b8de3c6779e060349bd5005bb4ae5bdcb437"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ef5dd1d39797044642dbe53eb2bc56435308432e9c7907728da74c69ee2adca"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a1aa370f978ac0bfb50374c3a40daa93fd56d47c0c70f0c79607fdac2ccbb42"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:454c203164e07a860dbeb3b1f4a733be52b0edbb4dd2e5bd75023ffa8b49403a"},
{file = "tokenizers-0.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:241482b940340fff26a2708cb9ba383a5bb8a2996d67a0ff2c4367bf4b86cc3a"}, {file = "tokenizers-0.15.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cf6b7f1d4dc59af960e6ffdc4faffe6460bbfa8dce27a58bf75755ffdb2526d"},
{file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:68f30b05f46a4d9aba88489eadd021904afe90e10a7950e28370d6e71b9db021"}, {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2ef09bbc16519f6c25d0c7fc0c6a33a6f62923e263c9d7cca4e58b8c61572afb"},
{file = "tokenizers-0.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5a3c5d8025529670462b881b7b2527aacb6257398c9ec8e170070432c3ae3a82"}, {file = "tokenizers-0.15.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c9a2ebdd2ad4ec7a68e7615086e633857c85e2f18025bd05d2a4399e6c5f7169"},
{file = "tokenizers-0.15.1-cp39-none-win32.whl", hash = "sha256:74d1827830f60a9d78da8f6d49a1fbea5422ce0eea42e2617877d23380a7efbc"}, {file = "tokenizers-0.15.2-cp39-none-win32.whl", hash = "sha256:918fbb0eab96fe08e72a8c2b5461e9cce95585d82a58688e7f01c2bd546c79d0"},
{file = "tokenizers-0.15.1-cp39-none-win_amd64.whl", hash = "sha256:9ff499923e4d6876d6b6a63ea84a56805eb35e91dd89b933a7aee0c56a3838c6"}, {file = "tokenizers-0.15.2-cp39-none-win_amd64.whl", hash = "sha256:524e60da0135e106b254bd71f0659be9f89d83f006ea9093ce4d1fab498c6d0d"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b3aa007a0f4408f62a8471bdaa3faccad644cbf2622639f2906b4f9b5339e8b8"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6a9b648a58281c4672212fab04e60648fde574877d0139cd4b4f93fe28ca8944"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f3d4176fa93d8b2070db8f3c70dc21106ae6624fcaaa334be6bdd3a0251e729e"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7c7d18b733be6bbca8a55084027f7be428c947ddf871c500ee603e375013ffba"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d0e463655ef8b2064df07bd4a445ed7f76f6da3b286b4590812587d42f80e89"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:13ca3611de8d9ddfbc4dc39ef54ab1d2d4aaa114ac8727dfdc6a6ec4be017378"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:089138fd0351b62215c462a501bd68b8df0e213edcf99ab9efd5dba7b4cb733e"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:237d1bf3361cf2e6463e6c140628e6406766e8b27274f5fcc62c747ae3c6f094"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e563ac628f5175ed08e950430e2580e544b3e4b606a0995bb6b52b3a3165728"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67a0fe1e49e60c664915e9fb6b0cb19bac082ab1f309188230e4b2920230edb3"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:244dcc28c5fde221cb4373961b20da30097669005b122384d7f9f22752487a46"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4e022fe65e99230b8fd89ebdfea138c24421f91c1a4f4781a8f5016fd5cdfb4d"},
{file = "tokenizers-0.15.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d82951d46052dddae1369e68ff799a0e6e29befa9a0b46e387ae710fd4daefb0"}, {file = "tokenizers-0.15.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d857be2df69763362ac699f8b251a8cd3fac9d21893de129bc788f8baaef2693"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b14296bc9059849246ceb256ffbe97f8806a9b5d707e0095c22db312f4fc014"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:708bb3e4283177236309e698da5fcd0879ce8fd37457d7c266d16b550bcbbd18"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0309357bb9b6c8d86cdf456053479d7112074b470651a997a058cd7ad1c4ea57"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c35e09e9899b72a76e762f9854e8750213f67567787d45f37ce06daf57ca78"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083f06e9d8d01b70b67bcbcb7751b38b6005512cce95808be6bf34803534a7e7"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1257f4394be0d3b00de8c9e840ca5601d0a4a8438361ce9c2b05c7d25f6057b"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85288aea86ada579789447f0dcec108ebef8da4b450037eb4813d83e4da9371e"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02272fe48280e0293a04245ca5d919b2c94a48b408b55e858feae9618138aeda"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:385e6fcb01e8de90c1d157ae2a5338b23368d0b1c4cc25088cdca90147e35d17"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dc3ad9ebc76eabe8b1d7c04d38be884b8f9d60c0cdc09b0aa4e3bcf746de0388"},
{file = "tokenizers-0.15.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:60067edfcbf7d6cd448ac47af41ec6e84377efbef7be0c06f15a7c1dd069e044"}, {file = "tokenizers-0.15.2-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:32e16bdeffa7c4f46bf2152172ca511808b952701d13e7c18833c0b73cb5c23f"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f7e37f89acfe237d4eaf93c3b69b0f01f407a7a5d0b5a8f06ba91943ea3cf10"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fb16ba563d59003028b678d2361a27f7e4ae0ab29c7a80690efa20d829c81fdb"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:6a63a15b523d42ebc1f4028e5a568013388c2aefa4053a263e511cb10aaa02f1"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:2277c36d2d6cdb7876c274547921a42425b6810d38354327dd65a8009acf870c"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2417d9e4958a6c2fbecc34c27269e74561c55d8823bf914b422e261a11fdd5fd"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1cf75d32e8d250781940d07f7eece253f2fe9ecdb1dc7ba6e3833fa17b82fcbc"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8550974bace6210e41ab04231e06408cf99ea4279e0862c02b8d47e7c2b2828"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b3b31884dc8e9b21508bb76da80ebf7308fdb947a17affce815665d5c4d028"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:194ba82129b171bcd29235a969e5859a93e491e9b0f8b2581f500f200c85cfdd"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10122d8d8e30afb43bb1fe21a3619f62c3e2574bff2699cf8af8b0b6c5dc4a3"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1bfd95eef8b01e6c0805dbccc8eaf41d8c5a84f0cce72c0ab149fe76aae0bce6"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d88b96ff0fe8e91f6ef01ba50b0d71db5017fa4e3b1d99681cec89a85faf7bf7"},
{file = "tokenizers-0.15.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b87a15dd72f8216b03c151e3dace00c75c3fe7b0ee9643c25943f31e582f1a34"}, {file = "tokenizers-0.15.2-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:37aaec5a52e959892870a7c47cef80c53797c0db9149d458460f4f31e2fb250e"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6ac22f358a0c2a6c685be49136ce7ea7054108986ad444f567712cf274b34cd8"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2ea752f2b0fe96eb6e2f3adbbf4d72aaa1272079b0dfa1145507bd6a5d537e6"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e9d1f046a9b9d9a95faa103f07db5921d2c1c50f0329ebba4359350ee02b18b"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b19a808d8799fda23504a5cd31d2f58e6f52f140380082b352f877017d6342b"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a0fd30a4b74485f6a7af89fffb5fb84d6d5f649b3e74f8d37f624cc9e9e97cf"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:64c86e5e068ac8b19204419ed8ca90f9d25db20578f5881e337d203b314f4104"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e45dc206b9447fa48795a1247c69a1732d890b53e2cc51ba42bc2fefa22407"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de19c4dc503c612847edf833c82e9f73cd79926a384af9d801dcf93f110cea4e"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eaff56ef3e218017fa1d72007184401f04cb3a289990d2b6a0a76ce71c95f96"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea09acd2fe3324174063d61ad620dec3bcf042b495515f27f638270a7d466e8b"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b41dc107e4a4e9c95934e79b025228bbdda37d9b153d8b084160e88d5e48ad6f"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cf27fd43472e07b57cf420eee1e814549203d56de00b5af8659cb99885472f1f"},
{file = "tokenizers-0.15.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1922b8582d0c33488764bcf32e80ef6054f515369e70092729c928aae2284bc2"}, {file = "tokenizers-0.15.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7ca22bd897537a0080521445d91a58886c8c04084a6a19e6c78c586e0cfa92a5"},
{file = "tokenizers-0.15.1.tar.gz", hash = "sha256:c0a331d6d5a3d6e97b7f99f562cee8d56797180797bc55f12070e495e717c980"}, {file = "tokenizers-0.15.2.tar.gz", hash = "sha256:e6e9c6e019dd5484be5beafc775ae6c925f4c69a3487040ed09b45e13df2cb91"},
] ]
[package.dependencies] [package.dependencies]
@@ -3281,13 +3281,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.27.0.post1" version = "0.27.1"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f"}, {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"},
{file = "uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907"}, {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"},
] ]
[package.dependencies] [package.dependencies]
+3 -3
View File
@@ -82,10 +82,10 @@ warn_untyped_fields = true
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120
target-version = "py311" target-version = "py311"
select = ["E", "F", "I"]
[tool.ruff.per-file-ignores] [tool.ruff.lint]
"test_main.py" = ["F403"] select = ["E", "F", "I"]
per-file-ignores = { "test_main.py" = ["F403"] }
[tool.black] [tool.black]
line-length = 120 line-length = 120
@@ -30,7 +30,7 @@ extension LogOnError<T> on AsyncValue<T> {
} }
if (hasError && !hasValue) { if (hasError && !hasValue) {
_asyncErrorLogger.severe("$error", error, stackTrace); _asyncErrorLogger.severe('Could not load value', error, stackTrace);
return onError?.call(error, stackTrace) ?? return onError?.call(error, stackTrace) ??
ScaffoldErrorBody(errorMsg: error?.toString()); ScaffoldErrorBody(errorMsg: error?.toString());
} }
@@ -0,0 +1,5 @@
import 'package:http/http.dart';
extension LoggerExtension on Response {
String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body";
}
+3 -4
View File
@@ -73,15 +73,14 @@ Future<void> initApp() async {
FlutterError.onError = (details) { FlutterError.onError = (details) {
FlutterError.presentError(details); FlutterError.presentError(details);
log.severe( log.severe(
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}', 'FlutterError - Catch all',
details, "${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
details.stack, details.stack,
); );
}; };
PlatformDispatcher.instance.onError = (error, stack) { PlatformDispatcher.instance.onError = (error, stack) {
log.severe('PlatformDispatcher - Catch all error: $error', error, stack); log.severe('PlatformDispatcher - Catch all', error, stack);
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
return true; return true;
}; };
+4 -2
View File
@@ -10,13 +10,14 @@ mixin ErrorLoggerMixin {
/// Else, logs the error to the overrided logger and returns an AsyncError<> /// Else, logs the error to the overrided logger and returns an AsyncError<>
AsyncFuture<T> guardError<T>( AsyncFuture<T> guardError<T>(
Future<T> Function() fn, { Future<T> Function() fn, {
required String errorMessage,
Level logLevel = Level.SEVERE, Level logLevel = Level.SEVERE,
}) async { }) async {
try { try {
final result = await fn(); final result = await fn();
return AsyncData(result); return AsyncData(result);
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace); logger.log(logLevel, errorMessage, error, stackTrace);
return AsyncError(error, stackTrace); return AsyncError(error, stackTrace);
} }
} }
@@ -26,12 +27,13 @@ mixin ErrorLoggerMixin {
Future<T> logError<T>( Future<T> logError<T>(
Future<T> Function() fn, { Future<T> Function() fn, {
required T defaultValue, required T defaultValue,
required String errorMessage,
Level logLevel = Level.SEVERE, Level logLevel = Level.SEVERE,
}) async { }) async {
try { try {
return await fn(); return await fn();
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace); logger.log(logLevel, errorMessage, error, stackTrace);
} }
return defaultValue; return defaultValue;
} }
@@ -24,6 +24,7 @@ class ActivityService with ErrorLoggerMixin {
return list != null ? list.map(Activity.fromDto).toList() : []; return list != null ? list.map(Activity.fromDto).toList() : [];
}, },
defaultValue: [], defaultValue: [],
errorMessage: "Failed to get all activities for album $albumId",
); );
} }
@@ -35,6 +36,7 @@ class ActivityService with ErrorLoggerMixin {
return dto?.comments ?? 0; return dto?.comments ?? 0;
}, },
defaultValue: 0, defaultValue: 0,
errorMessage: "Failed to statistics for album $albumId",
); );
} }
@@ -45,6 +47,7 @@ class ActivityService with ErrorLoggerMixin {
return true; return true;
}, },
defaultValue: false, defaultValue: false,
errorMessage: "Failed to delete activity",
); );
} }
@@ -54,21 +57,24 @@ class ActivityService with ErrorLoggerMixin {
String? assetId, String? assetId,
String? comment, String? comment,
}) async { }) async {
return guardError(() async { return guardError(
final dto = await _apiService.activityApi.createActivity( () async {
ActivityCreateDto( final dto = await _apiService.activityApi.createActivity(
albumId: albumId, ActivityCreateDto(
type: type == ActivityType.comment albumId: albumId,
? ReactionType.comment type: type == ActivityType.comment
: ReactionType.like, ? ReactionType.comment
assetId: assetId, : ReactionType.like,
comment: comment, assetId: assetId,
), comment: comment,
); ),
if (dto != null) { );
return Activity.fromDto(dto); if (dto != null) {
} return Activity.fromDto(dto);
throw NoResponseDtoError(); }
}); throw NoResponseDtoError();
},
errorMessage: "Failed to create $type for album $albumId",
);
} }
} }
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.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 { class AlbumThumbnailCard extends StatelessWidget {
final Function()? onTap; final Function()? onTap;
@@ -45,8 +45,8 @@ class AlbumThumbnailCard extends StatelessWidget {
); );
} }
buildAlbumThumbnail() => ImmichImage.thumbnail( buildAlbumThumbnail() => ImmichThumbnail(
album.thumbnail.value, asset: album.thumbnail.value,
width: cardSize, width: cardSize,
height: cardSize, height: cardSize,
); );
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.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 { class SharedAlbumThumbnailImage extends HookConsumerWidget {
final Asset asset; final Asset asset;
@@ -16,8 +16,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
}, },
child: Stack( child: Stack(
children: [ children: [
ImmichImage.thumbnail( ImmichThumbnail(
asset, asset: asset,
width: 500, width: 500,
height: 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/routing/router.dart';
import 'package:immich_mobile/shared/providers/user.provider.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_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
@RoutePage() @RoutePage()
class SharingPage extends HookConsumerWidget { class SharingPage extends HookConsumerWidget {
@@ -72,8 +72,8 @@ class SharingPage extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
leading: ClipRRect( leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichImage.thumbnail( child: ImmichThumbnail(
album.thumbnail.value, asset: album.thumbnail.value,
width: 60, width: 60,
height: 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 /// The local image provider for an asset
/// Only viable /// Only viable
class ImmichLocalImageProvider extends ImageProvider<Asset> { class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
final Asset asset; final Asset asset;
ImmichLocalImageProvider({ ImmichLocalImageProvider({
@@ -21,15 +21,18 @@ class ImmichLocalImageProvider extends ImageProvider<Asset> {
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load. /// that describes the precise image to load.
@override @override
Future<Asset> obtainKey(ImageConfiguration configuration) { Future<ImmichLocalImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(asset); return SynchronousFuture(this);
} }
@override @override
ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(
ImmichLocalImageProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents), codec: _codec(key.asset, decode, chunkEvents),
scale: 1.0, scale: 1.0,
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
informationCollector: () sync* { informationCollector: () sync* {
@@ -82,11 +85,6 @@ class ImmichLocalImageProvider extends ImageProvider<Asset> {
yield codec; yield codec;
} catch (error) { } catch (error) {
throw StateError("Loading asset ${asset.fileName} failed"); 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'; import 'package:immich_mobile/utils/image_url_builder.dart';
/// Our Image Provider HTTP client to make the request /// 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 /// The remote image provider
class ImmichRemoteImageProvider extends ImageProvider<String> { class ImmichRemoteImageProvider
extends ImageProvider<ImmichRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch /// The [Asset.remoteId] of the asset to fetch
final String assetId; final String assetId;
@@ -32,16 +35,20 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load. /// that describes the precise image to load.
@override @override
Future<String> obtainKey(ImageConfiguration configuration) { Future<ImmichRemoteImageProvider> obtainKey(
return SynchronousFuture('$assetId,$isThumbnail'); ImageConfiguration configuration,
) {
return SynchronousFuture(this);
} }
@override @override
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(
final id = key.split(',').first; ImmichRemoteImageProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(id, decode, chunkEvents), codec: _codec(key, decode, chunkEvents),
scale: 1.0, scale: 1.0,
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
); );
@@ -61,14 +68,14 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
// Streams in each stage of the image as we ask for it // Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec( Stream<ui.Codec> _codec(
String key, ImmichRemoteImageProvider key,
ImageDecoderCallback decode, ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents, StreamController<ImageChunkEvent> chunkEvents,
) async* { ) async* {
// Load a preview to the chunk events // Load a preview to the chunk events
if (_loadPreview || isThumbnail) { if (_loadPreview || key.isThumbnail) {
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
assetId, key.assetId,
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
); );
@@ -80,14 +87,14 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
} }
// Guard thumnbail rendering // Guard thumnbail rendering
if (isThumbnail) { if (key.isThumbnail) {
await chunkEvents.close(); await chunkEvents.close();
return; return;
} }
// Load the higher resolution version of the image // Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId( final url = getThumbnailUrlForRemoteId(
assetId, key.assetId,
type: api.ThumbnailFormat.JPEG, type: api.ThumbnailFormat.JPEG,
); );
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
@@ -96,7 +103,7 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
// Load the final remote image // Load the final remote image
if (_useOriginal) { if (_useOriginal) {
// Load the original image // Load the original image
final url = getImageUrlFromId(assetId); final url = getImageUrlFromId(key.assetId);
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
yield codec; yield codec;
} }
@@ -137,7 +144,7 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false; if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return assetId == other.assetId; return assetId == other.assetId && isThumbnail == other.isThumbnail;
} }
@override @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/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.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 /// The remote image provider
class ImmichRemoteThumbnailProvider extends ImageProvider<String> { class ImmichRemoteThumbnailProvider
extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch /// The [Asset.remoteId] of the asset to fetch
final String assetId; final String assetId;
/// Our HTTP client to make the request
final _httpClient = HttpClient()..autoUncompress = false;
ImmichRemoteThumbnailProvider({ ImmichRemoteThumbnailProvider({
required this.assetId, required this.assetId,
}); });
@@ -27,12 +30,17 @@ class ImmichRemoteThumbnailProvider extends ImageProvider<String> {
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load. /// that describes the precise image to load.
@override @override
Future<String> obtainKey(ImageConfiguration configuration) { Future<ImmichRemoteThumbnailProvider> obtainKey(
return SynchronousFuture(assetId); ImageConfiguration configuration,
) {
return SynchronousFuture(this);
} }
@override @override
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(
ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents), 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 // Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec( Stream<ui.Codec> _codec(
String key, ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode, ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents, StreamController<ImageChunkEvent> chunkEvents,
) async* { ) async* {
// Load a preview to the chunk events // Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
assetId, key.assetId,
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
); );
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
@@ -39,7 +40,8 @@ class ImageViewerService {
final failedResponse = final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionReponse; imageResponse.statusCode != 200 ? imageResponse : motionReponse;
_log.severe( _log.severe(
"Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}", "Motion asset download failed",
failedResponse.toLoggerString(),
); );
return false; return false;
} }
@@ -75,9 +77,7 @@ class ImageViewerService {
.downloadFileWithHttpInfo(asset.remoteId!); .downloadFileWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe( _log.severe("Asset download failed", res.toLoggerString());
"Asset download failed with status - ${res.statusCode} and response - ${res.body}",
);
return false; return false;
} }
@@ -98,7 +98,7 @@ class ImageViewerService {
return entity != null; return entity != null;
} }
} catch (error, stack) { } catch (error, stack) {
_log.severe("Error saving file ${error.toString()}", error, stack); _log.severe("Error saving downloaded asset", error, stack);
return false; return false;
} finally { } finally {
// Clear temp files // Clear temp files
@@ -48,7 +48,7 @@ class DescriptionInput extends HookConsumerWidget {
); );
} catch (error, stack) { } catch (error, stack) {
hasError.value = true; hasError.value = true;
_log.severe("Error updating description $error", error, stack); _log.severe("Error updating description", error, stack);
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "description_input_submit_error".tr(), msg: "description_input_submit_error".tr(),
@@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
class VideoPlayerControls extends ConsumerStatefulWidget { class VideoPlayerControls extends ConsumerStatefulWidget {
@@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
children: [ children: [
if (_displayBufferingIndicator) if (_displayBufferingIndicator)
const Center( const Center(
child: ImmichLoadingIndicator(), child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
) )
else else
_buildHitArea(), _buildHitArea(),
@@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
@override @override
void dispose() { void dispose() {
_dispose(); _dispose();
super.dispose(); super.dispose();
} }
@@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
final oldController = _chewieController; final oldController = _chewieController;
_chewieController = ChewieController.of(context); _chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController; controller = chewieController.videoPlayerController;
_latestValue = controller.value;
if (oldController != chewieController) { if (oldController != chewieController) {
_dispose(); _dispose();
@@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (_latestValue.isPlaying) { if (!_latestValue.isPlaying) {
ref.read(showControlsProvider.notifier).show = false;
} else {
_playPause(); _playPause();
ref.read(showControlsProvider.notifier).show = false;
} }
ref.read(showControlsProvider.notifier).show = false;
}, },
child: CenterPlayButton( child: CenterPlayButton(
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
@@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
} }
Future<void> _initialize() async { Future<void> _initialize() async {
ref.read(showControlsProvider.notifier).show = false;
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
controller.addListener(_updateState);
_latestValue = controller.value; _latestValue = controller.value;
controller.addListener(_updateState);
if (controller.value.isPlaying || chewieController.autoPlay) { if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer(); _startHideTimer();
@@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
} }
void _startHideTimer() { void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer.isNegative final hideControlsTimer = chewieController.hideControlsTimer;
? ChewieController.defaultHideControlsTimer _hideTimer?.cancel();
: chewieController.hideControlsTimer;
_hideTimer = Timer(hideControlsTimer, () { _hideTimer = Timer(hideControlsTimer, () {
ref.read(showControlsProvider.notifier).show = false; ref.read(showControlsProvider.notifier).show = false;
}); });
@@ -1,8 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'dart:ui' as ui;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:auto_route/auto_route.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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@@ -10,6 +10,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/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/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.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'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
@@ -26,13 +27,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/home/ui/upload_dialog.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.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/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.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/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.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/immich_toast.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.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'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
@@ -481,15 +482,9 @@ class GalleryViewerPage extends HookConsumerWidget {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage( child: Image(
fit: BoxFit.cover, fit: BoxFit.cover,
imageUrl: image: ImmichRemoteImageProvider(assetId: assetId!),
'${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),
), ),
), ),
), ),
@@ -704,6 +699,18 @@ class GalleryViewerPage extends HookConsumerWidget {
); );
} }
useEffect(
() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) { ref.listen(showControlsProvider, (_, show) {
if (show) { if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -728,9 +735,15 @@ class GalleryViewerPage extends HookConsumerWidget {
isZoomed.value = state != PhotoViewScaleState.initial; isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value; ref.read(showControlsProvider.notifier).show = !isZoomed.value;
}, },
loadingBuilder: (context, event, index) => ImmichImage.thumbnail( loadingBuilder: (context, event, index) => ImageFiltered(
asset(), imageFilter: ui.ImageFilter.blur(
fit: BoxFit.contain, sigmaX: 1,
sigmaY: 1,
),
child: ImmichThumbnail(
asset: asset(),
fit: BoxFit.contain,
),
), ),
pageController: controller, pageController: controller,
scrollPhysics: isZoomed.value scrollPhysics: isZoomed.value
@@ -794,7 +807,9 @@ class GalleryViewerPage extends HookConsumerWidget {
minScale: 1.0, minScale: 1.0,
basePosition: Alignment.center, basePosition: Alignment.center,
child: VideoViewerPage( child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true, onPlaying: () {
isPlayingVideo.value = true;
},
onPaused: () => onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false, (_) => isPlayingVideo.value = false,
@@ -1,23 +1,15 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage() @RoutePage()
// ignore: must_be_immutable // ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget { class VideoViewerPage extends HookWidget {
final Asset asset; final Asset asset;
final bool isMotionVideo; final bool isMotionVideo;
final Widget? placeholder; final Widget? placeholder;
@@ -42,211 +34,49 @@ class VideoViewerPage extends HookConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
if (asset.isLocal && asset.livePhotoVideoId == null) { final controller = useChewieController(
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!)); asset,
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: videoFile.when(
data: (data) => VideoPlayer(
file: data,
isMotionVideo: false,
onVideoEnded: () {},
),
error: (error, stackTrace) => Icon(
Icons.image_not_supported_outlined,
color: context.primaryColor,
),
loading: () => showDownloadingIndicator
? const Center(child: ImmichLoadingIndicator())
: Container(),
),
);
}
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final String videoUrl = isMotionVideo
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
return Stack(
children: [
VideoPlayer(
url: videoUrl,
accessToken: Store.get(StoreKey.accessToken),
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
onPaused: onPaused,
onPlaying: onPlaying,
placeholder: placeholder,
hideControlsTimer: hideControlsTimer,
showControls: showControls,
showDownloadingIndicator: showDownloadingIndicator,
),
AnimatedOpacity(
duration: const Duration(milliseconds: 400),
opacity: (downloadAssetStatus == DownloadAssetStatus.loading &&
showDownloadingIndicator)
? 1.0
: 0.0,
child: SizedBox(
height: context.height,
width: context.width,
child: const Center(
child: ImmichLoadingIndicator(),
),
),
),
],
);
}
}
final _fileFamily =
FutureProvider.family<File, AssetEntity>((ref, entity) async {
final file = await entity.file;
if (file == null) {
throw Exception();
}
return file;
});
class VideoPlayer extends StatefulWidget {
final String? url;
final String? accessToken;
final File? file;
final bool isMotionVideo;
final VoidCallback? onVideoEnded;
final Duration hideControlsTimer;
final bool showControls;
final Function()? onPlaying;
final Function()? onPaused;
/// The placeholder to show while the video is loading
/// usually, a thumbnail of the video
final Widget? placeholder;
final bool showDownloadingIndicator;
const VideoPlayer({
super.key,
this.url,
this.accessToken,
this.file,
this.onVideoEnded,
required this.isMotionVideo,
this.onPlaying,
this.onPaused,
this.placeholder,
this.hideControlsTimer = const Duration(
seconds: 5,
),
this.showControls = true,
this.showDownloadingIndicator = true,
});
@override
State<VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
@override
void initState() {
super.initState();
initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.isInitialized) {
if (videoPlayerController.value.isPlaying) {
WakelockPlus.enable();
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
WakelockPlus.disable();
widget.onPaused?.call();
}
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
WakelockPlus.disable();
widget.onVideoEnded?.call();
}
}
});
}
Future<void> initializePlayer() async {
try {
videoPlayerController = widget.file == null
? VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: {"x-immich-user-token": widget.accessToken ?? ""},
)
: VideoPlayerController.file(widget.file!);
await videoPlayerController.initialize();
_createChewieController();
setState(() {});
} catch (e) {
debugPrint("ERROR initialize video player $e");
}
}
_createChewieController() {
chewieController = ChewieController(
controlsSafeAreaMinimum: const EdgeInsets.only( controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100, bottom: 100,
), ),
showOptions: true, placeholder: placeholder,
showControlsOnInitialize: false, showControls: showControls && !isMotionVideo,
videoPlayerController: videoPlayerController, hideControlsTimer: hideControlsTimer,
autoPlay: true,
autoInitialize: true,
allowFullScreen: false,
allowedScreenSleep: false,
showControls: widget.showControls && !widget.isMotionVideo,
customControls: const VideoPlayerControls(), customControls: const VideoPlayerControls(),
hideControlsTimer: widget.hideControlsTimer, onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
);
// Loading
return PopScope(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Builder(
builder: (context) {
if (controller == null) {
return Stack(
children: [
if (placeholder != null) placeholder!,
const DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
],
);
}
final size = MediaQuery.of(context).size;
return SizedBox(
height: size.height,
width: size.width,
child: Chewie(
controller: controller,
),
);
},
),
),
); );
} }
@override
void dispose() {
super.dispose();
videoPlayerController.pause();
videoPlayerController.dispose();
chewieController?.dispose();
}
@override
Widget build(BuildContext context) {
if (chewieController?.videoPlayerController.value.isInitialized == true) {
return SizedBox(
height: context.height,
width: context.width,
child: Chewie(
controller: chewieController!,
),
);
} else {
return SizedBox(
height: context.height,
width: context.width,
child: Center(
child: Stack(
children: [
if (widget.placeholder != null) widget.placeholder!,
if (widget.showDownloadingIndicator)
const Center(
child: ImmichLoadingIndicator(),
),
],
),
),
);
}
}
} }
@@ -245,7 +245,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} catch (e, stack) { } catch (e, stack) {
log.severe( log.severe(
"Failed to get thumbnail for album ${album.name}", "Failed to get thumbnail for album ${album.name}",
e.toString(), e,
stack, stack,
); );
} }
@@ -0,0 +1,60 @@
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/ui/hooks/blurhash_hook.dart';
class BlurhashThumb extends HookWidget {
final double height;
final double width;
final Asset asset;
final EdgeInsets margin;
const BlurhashThumb({
super.key,
required this.height,
required this.width,
required this.asset,
required this.margin,
});
@override
Widget build(BuildContext context) {
final blurhash = useBlurHashRef(asset).value;
if (blurhash == null) {
return SizedBox(
height: height,
width: width,
);
}
return Padding(
padding: margin,
child: Image.memory(
blurhash,
gaplessPlayback: true,
frameBuilder: (
BuildContext context,
Widget child,
int? frame,
bool wasSynchronouslyLoaded,
) {
if (wasSynchronouslyLoaded) {
return child;
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
child: frame != null
? child
: SizedBox(
height: height,
width: width,
),
);
},
fit: BoxFit.cover,
height: height,
width: width,
),
);
}
}
@@ -1,4 +1,5 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:math'; import 'dart:math';
@@ -6,12 +7,15 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/blurhash_thumb.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart'; import 'asset_grid_data_structure.dart';
@@ -325,32 +329,33 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
} }
/// A single row of all placeholder widgets /// A single row of all placeholder widgets
class _PlaceholderRow extends StatelessWidget { class _PlaceholderRow extends HookWidget {
final int number;
final double width; final double width;
final double height; final double height;
final double margin; final double margin;
final List<Asset> assets;
const _PlaceholderRow({ const _PlaceholderRow({
super.key, super.key,
required this.number,
required this.width, required this.width,
required this.height, required this.height,
required this.margin, required this.margin,
required this.assets,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
for (int i = 0; i < number; i++) for (int i = 0; i < assets.length; i++)
ThumbnailPlaceholder( BlurhashThumb(
key: ValueKey(i), key: ValueKey(i),
asset: assets[i],
width: width, width: width,
height: height, height: height,
margin: EdgeInsets.only( margin: EdgeInsets.only(
bottom: margin, bottom: margin,
right: i + 1 == number ? 0.0 : margin, right: i + 1 == assets.length ? 0.0 : margin,
), ),
), ),
], ],
@@ -401,9 +406,9 @@ class _Section extends StatelessWidget {
final width = constraints.maxWidth / assetsPerRow - final width = constraints.maxWidth / assetsPerRow -
margin * (assetsPerRow - 1) / assetsPerRow; margin * (assetsPerRow - 1) / assetsPerRow;
final rows = (section.count + assetsPerRow - 1) ~/ assetsPerRow; final rows = (section.count + assetsPerRow - 1) ~/ assetsPerRow;
final List<Asset> assetsToRender = scrolling final List<Asset> assetsToRender = //scrolling
? [] //? []
: renderList.loadAssets(section.offset, section.count); renderList.loadAssets(section.offset, section.count);
return Column( return Column(
key: ValueKey(section.offset), key: ValueKey(section.offset),
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -422,18 +427,22 @@ class _Section extends StatelessWidget {
selectAssets: selectAssets, selectAssets: selectAssets,
deselectAssets: deselectAssets, deselectAssets: deselectAssets,
), ),
for (int i = 0; i < rows; i++) Stack(
scrolling children: [
? _PlaceholderRow( for (int i = 0; i < rows; i++)
key: ValueKey(i), _PlaceholderRow(
number: i + 1 == rows key: ValueKey('placeholder-$i'),
? section.count - i * assetsPerRow assets: assetsToRender.nestedSlice(
: assetsPerRow, i * assetsPerRow,
width: width, min((i + 1) * assetsPerRow, section.count),
height: width, ),
margin: margin, width: width,
) height: width,
: _AssetRow( margin: margin,
),
if (!scrolling)
for (int i = 0; i < rows; i++)
_AssetRow(
key: ValueKey(i), key: ValueKey(i),
assets: assetsToRender.nestedSlice( assets: assetsToRender.nestedSlice(
i * assetsPerRow, i * assetsPerRow,
@@ -454,6 +463,8 @@ class _Section extends StatelessWidget {
onSelect: (asset) => selectAssets([asset]), onSelect: (asset) => selectAssets([asset]),
onDeselect: (asset) => deselectAssets([asset]), onDeselect: (asset) => deselectAssets([asset]),
), ),
],
),
], ],
); );
}, },
@@ -4,7 +4,7 @@ import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.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:immich_mobile/utils/storage_indicator.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
@@ -134,10 +134,10 @@ class ThumbnailImage extends StatelessWidget {
tag: isFromDto tag: isFromDto
? '${asset.remoteId}-$heroOffset' ? '${asset.remoteId}-$heroOffset'
: asset.id + heroOffset, : asset.id + heroOffset,
child: ImmichImage.thumbnail( child: ImmichThumbnail(
asset, asset: asset,
height: 300, height: 250,
width: 300, width: 250,
), ),
), ),
); );
@@ -108,7 +108,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
.then((_) => log.info("Logout was successful for $userEmail")) .then((_) => log.info("Logout was successful for $userEmail"))
.onError( .onError(
(error, stackTrace) => (error, stackTrace) =>
log.severe("Error logging out $userEmail", error, stackTrace), log.severe("Logout failed for $userEmail", error, stackTrace),
); );
await Future.wait([ await Future.wait([
@@ -129,8 +129,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
shouldChangePassword: false, shouldChangePassword: false,
isAuthenticated: false, isAuthenticated: false,
); );
} catch (e) { } catch (e, stack) {
log.severe("Error logging out $e"); log.severe('Logout failed', e, stack);
} }
} }
@@ -36,7 +36,7 @@ class OAuthService {
), ),
); );
} catch (e, stack) { } catch (e, stack) {
log.severe("Error performing oAuthLogin: ${e.toString()}", e, stack); log.severe("OAuth login failed", e, stack);
return null; return null;
} }
} }
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart'; import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -51,7 +52,8 @@ class MapStateNotifier extends _$MapStateNotifier {
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
); );
_log.severe( _log.severe(
"Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}", "Cannot fetch map light style",
lightResponse.toLoggerString(),
); );
return; return;
} }
@@ -77,9 +79,7 @@ class MapStateNotifier extends _$MapStateNotifier {
state = state.copyWith( state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
); );
_log.severe( _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
"Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}",
);
return; return;
} }
@@ -28,6 +28,7 @@ class MapSerivce with ErrorLoggerMixin {
return markers?.map(MapMarker.fromDto) ?? []; return markers?.map(MapMarker.fromDto) ?? [];
}, },
defaultValue: [], defaultValue: [],
errorMessage: "Failed to get map markers",
); );
} }
} }
+2 -4
View File
@@ -105,10 +105,8 @@ class MapUtils {
timeLimit: const Duration(seconds: 5), timeLimit: const Duration(seconds: 5),
); );
return (currentUserLocation, null); return (currentUserLocation, null);
} catch (error) { } catch (error, stack) {
_log.severe( _log.severe("Cannot get user's current location", error, stack);
"Cannot get user's current location due to ${error.toString()}",
);
return (null, LocationPermission.unableToDetermine); return (null, LocationPermission.unableToDetermine);
} }
} }
@@ -147,7 +147,7 @@ class MapAssetGrid extends HookConsumerWidget {
}, },
error: (error, stackTrace) { error: (error, stackTrace) {
log.warning( log.warning(
"Cannot get assets in the current map bounds $error", "Cannot get assets in the current map bounds",
error, error,
stackTrace, stackTrace,
); );
@@ -47,7 +47,7 @@ class MemoryService {
return memories.isNotEmpty ? memories : null; return memories.isNotEmpty ? memories : null;
} catch (error, stack) { } catch (error, stack) {
log.severe("Cannot get memories ${error.toString()}", error, stack); log.severe("Cannot get memories", error, stack);
return null; return null;
} }
} }
@@ -6,6 +6,7 @@ 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/asset.dart';
import 'package:immich_mobile/shared/models/store.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_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
class MemoryCard extends StatelessWidget { class MemoryCard extends StatelessWidget {
final Asset asset; final Asset asset;
@@ -42,9 +43,8 @@ class MemoryCard extends StatelessWidget {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: ImmichImage.imageProvider( image: ImmichThumbnail.imageProvider(
asset: asset, asset: asset,
isThumbnail: true,
), ),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
@@ -55,9 +55,9 @@ class MemoryCard extends StatelessWidget {
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Determine the fit using the aspect ratio // Determine the fit using the aspect ratio
BoxFit fit = BoxFit.fitWidth; BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) { if (asset.width != null && asset.height != null) {
final aspectRatio = asset.height! / asset.width!; final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio = final phoneAspectRatio =
constraints.maxWidth / constraints.maxHeight; constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction // Look for a 25% difference in either direction
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/memories/ui/memory_epilogue.dart';
import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart'; import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart';
import 'package:immich_mobile/shared/models/asset.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_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
@RoutePage() @RoutePage()
class MemoryPage extends HookConsumerWidget { class MemoryPage extends HookConsumerWidget {
@@ -120,9 +121,8 @@ class MemoryPage extends HookConsumerWidget {
context, context,
), ),
precacheImage( precacheImage(
ImmichImage.imageProvider( ImmichThumbnail.imageProvider(
asset: asset, asset: asset,
isThumbnail: true,
), ),
context, context,
), ),
@@ -40,7 +40,7 @@ class PartnerService {
return userDtos.map((u) => User.fromPartnerDto(u)).toList(); return userDtos.map((u) => User.fromPartnerDto(u)).toList();
} }
} catch (e) { } catch (e) {
_log.warning("failed to get partners for direction $direction:\n$e"); _log.warning("Failed to get partners for direction $direction", e);
} }
return null; return null;
} }
@@ -51,7 +51,7 @@ class PartnerService {
partner.isPartnerSharedBy = false; partner.isPartnerSharedBy = false;
await _db.writeTxn(() => _db.users.put(partner)); await _db.writeTxn(() => _db.users.put(partner));
} catch (e) { } catch (e) {
_log.warning("failed to remove partner ${partner.id}:\n$e"); _log.warning("Failed to remove partner ${partner.id}", e);
return false; return false;
} }
return true; return true;
@@ -66,7 +66,7 @@ class PartnerService {
return true; return true;
} }
} catch (e) { } catch (e) {
_log.warning("failed to add partner ${partner.id}:\n$e"); _log.warning("Failed to add partner ${partner.id}", e);
} }
return false; return false;
} }
@@ -81,7 +81,7 @@ class PartnerService {
return true; return true;
} }
} catch (e) { } catch (e) {
_log.warning("failed to update partner ${partner.id}:\n$e"); _log.warning("Failed to update partner ${partner.id}", e);
} }
return false; return false;
} }
@@ -22,7 +22,7 @@ class SharedLinkService {
? AsyncData(list.map(SharedLink.fromDto).toList()) ? AsyncData(list.map(SharedLink.fromDto).toList())
: const AsyncData([]); : const AsyncData([]);
} catch (e, stack) { } catch (e, stack) {
_log.severe("failed to fetch shared links - $e"); _log.severe("Failed to fetch shared links", e, stack);
return AsyncError(e, stack); return AsyncError(e, stack);
} }
} }
@@ -31,7 +31,7 @@ class SharedLinkService {
try { try {
return await _apiService.sharedLinkApi.removeSharedLink(id); return await _apiService.sharedLinkApi.removeSharedLink(id);
} catch (e) { } catch (e) {
_log.severe("failed to delete shared link id - $id with error - $e"); _log.severe("Failed to delete shared link id - $id", e);
} }
} }
@@ -81,7 +81,7 @@ class SharedLinkService {
} }
} }
} catch (e) { } catch (e) {
_log.severe("failed to create shared link with error - $e"); _log.severe("Failed to create shared link", e);
} }
return null; return null;
} }
@@ -113,7 +113,7 @@ class SharedLinkService {
return SharedLink.fromDto(responseDto); return SharedLink.fromDto(responseDto);
} }
} catch (e) { } catch (e) {
_log.severe("failed to update shared link id - $id with error - $e"); _log.severe("Failed to update shared link id - $id", e);
} }
return null; return null;
} }
@@ -44,7 +44,7 @@ class TrashNotifier extends StateNotifier<bool> {
.read(syncServiceProvider) .read(syncServiceProvider)
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList()); .handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack); _log.severe("Cannot empty trash", error, stack);
} }
} }
@@ -70,7 +70,7 @@ class TrashNotifier extends StateNotifier<bool> {
return isRemoved; return isRemoved;
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack); _log.severe("Cannot remove assets", error, stack);
} }
return false; return false;
} }
@@ -93,7 +93,7 @@ class TrashNotifier extends StateNotifier<bool> {
return true; return true;
} }
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore assets", error, stack);
} }
return false; return false;
} }
@@ -123,7 +123,7 @@ class TrashNotifier extends StateNotifier<bool> {
await _db.assets.putAll(updatedAssets); await _db.assets.putAll(updatedAssets);
}); });
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore trash", error, stack);
} }
} }
} }
@@ -25,7 +25,7 @@ class TrashService {
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds)); await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds));
return true; return true;
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore assets ${error.toString()}", error, stack); _log.severe("Cannot restore assets", error, stack);
return false; return false;
} }
} }
@@ -34,7 +34,7 @@ class TrashService {
try { try {
await _apiService.trashApi.emptyTrash(); await _apiService.trashApi.emptyTrash();
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack); _log.severe("Cannot empty trash", error, stack);
} }
} }
@@ -42,7 +42,7 @@ class TrashService {
try { try {
await _apiService.trashApi.restoreTrash(); await _apiService.trashApi.restoreTrash();
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore trash", error, stack);
} }
} }
} }
+17 -14
View File
@@ -1,8 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -16,28 +16,31 @@ class AuthGuard extends AutoRouteGuard {
resolver.next(true); resolver.next(true);
try { try {
var res = await _apiService.authenticationApi.validateAccessToken(); // Look in the store for an access token
Store.get(StoreKey.accessToken);
// Validate the access token with the server
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) { if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login // If the access token is invalid, take user back to login
_log.fine("User token is invalid. Redirecting to login"); _log.fine('User token is invalid. Redirecting to login');
router.replaceAll([const LoginRoute()]); router.replaceAll([const LoginRoute()]);
} }
} on StoreKeyNotFoundException catch (_) {
// If there is no access token, take us to the login page
_log.warning('No access token in the store.');
router.replaceAll([const LoginRoute()]);
return;
} on ApiException catch (e) { } on ApiException catch (e) {
if (e.code == HttpStatus.badRequest && // On an unauthorized request, take us to the login page
e.innerException is SocketException) { if (e.code == HttpStatus.unauthorized) {
// offline? _log.warning("Unauthorized access token.");
_log.fine(
"Unable to validate user token. User may be offline and offline browsing is allowed.",
);
} else {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]); router.replaceAll([const LoginRoute()]);
return; return;
} }
} catch (e) { } catch (e) {
debugPrint("Error [onNavigation] ${e.toString()}"); // Otherwise, this is not fatal, but we still log the warning
router.replaceAll([const LoginRoute()]); _log.warning('Error validating access token from server: $e');
return;
} }
} }
} }
+2 -2
View File
@@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
void Function()? onPaused, void Function()? onPaused,
Widget? placeholder, Widget? placeholder,
bool showControls = true, bool showControls = true,
Duration hideControlsTimer = const Duration(seconds: 5), Duration hideControlsTimer = const Duration(milliseconds: 1500),
bool showDownloadingIndicator = true, bool showDownloadingIndicator = true,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
@@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused, this.onPaused,
this.placeholder, this.placeholder,
this.showControls = true, this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5), this.hideControlsTimer = const Duration(milliseconds: 1500),
this.showDownloadingIndicator = true, this.showDownloadingIndicator = true,
}); });
+14 -1
View File
@@ -38,7 +38,8 @@ class Asset {
// stack handling to properly handle it // stack handling to properly handle it
stackParentId = stackParentId =
remote.stackParentId == remote.id ? null : remote.stackParentId, remote.stackParentId == remote.id ? null : remote.stackParentId,
stackCount = remote.stackCount; stackCount = remote.stackCount,
thumbhash = remote.thumbhash;
Asset.local(AssetEntity local, List<int> hash) Asset.local(AssetEntity local, List<int> hash)
: localId = local.id, : localId = local.id,
@@ -91,6 +92,7 @@ class Asset {
this.stackCount = 0, this.stackCount = 0,
this.isReadOnly = false, this.isReadOnly = false,
this.isOffline = false, this.isOffline = false,
this.thumbhash,
}); });
@ignore @ignore
@@ -119,6 +121,8 @@ class Asset {
/// because Isar cannot sort lists of byte arrays /// because Isar cannot sort lists of byte arrays
String checksum; String checksum;
String? thumbhash;
@Index(unique: false, replace: false, type: IndexType.hash) @Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId; String? remoteId;
@@ -171,6 +175,11 @@ class Asset {
int? stackCount; int? stackCount;
/// Aspect ratio of the asset
@ignore
double? get aspectRatio =>
width == null || height == null ? 0 : width! / height!;
/// `true` if this [Asset] is present on the device /// `true` if this [Asset] is present on the device
@ignore @ignore
bool get isLocal => localId != null; bool get isLocal => localId != null;
@@ -274,6 +283,7 @@ class Asset {
a.exifInfo?.latitude != exifInfo?.latitude || a.exifInfo?.latitude != exifInfo?.latitude ||
a.exifInfo?.longitude != exifInfo?.longitude || a.exifInfo?.longitude != exifInfo?.longitude ||
// no local stack count or different count from remote // no local stack count or different count from remote
a.thumbhash != thumbhash ||
((stackCount == null && a.stackCount != null) || ((stackCount == null && a.stackCount != null) ||
(stackCount != null && (stackCount != null &&
a.stackCount != null && a.stackCount != null &&
@@ -338,6 +348,7 @@ class Asset {
isReadOnly: a.isReadOnly, isReadOnly: a.isReadOnly,
isOffline: a.isOffline, isOffline: a.isOffline,
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
thumbhash: a.thumbhash,
); );
} else { } else {
// add only missing values (and set isLocal to true) // add only missing values (and set isLocal to true)
@@ -374,6 +385,7 @@ class Asset {
ExifInfo? exifInfo, ExifInfo? exifInfo,
String? stackParentId, String? stackParentId,
int? stackCount, int? stackCount,
String? thumbhash,
}) => }) =>
Asset( Asset(
id: id ?? this.id, id: id ?? this.id,
@@ -398,6 +410,7 @@ class Asset {
exifInfo: exifInfo ?? this.exifInfo, exifInfo: exifInfo ?? this.exifInfo,
stackParentId: stackParentId ?? this.stackParentId, stackParentId: stackParentId ?? this.stackParentId,
stackCount: stackCount ?? this.stackCount, stackCount: stackCount ?? this.stackCount,
thumbhash: thumbhash ?? this.thumbhash,
); );
Future<void> put(Isar db) async { Future<void> put(Isar db) async {
+209 -11
View File
@@ -102,19 +102,24 @@ const AssetSchema = CollectionSchema(
name: r'stackParentId', name: r'stackParentId',
type: IsarType.string, type: IsarType.string,
), ),
r'type': PropertySchema( r'thumbhash': PropertySchema(
id: 17, id: 17,
name: r'thumbhash',
type: IsarType.string,
),
r'type': PropertySchema(
id: 18,
name: r'type', name: r'type',
type: IsarType.byte, type: IsarType.byte,
enumMap: _AssettypeEnumValueMap, enumMap: _AssettypeEnumValueMap,
), ),
r'updatedAt': PropertySchema( r'updatedAt': PropertySchema(
id: 18, id: 19,
name: r'updatedAt', name: r'updatedAt',
type: IsarType.dateTime, type: IsarType.dateTime,
), ),
r'width': PropertySchema( r'width': PropertySchema(
id: 19, id: 20,
name: r'width', name: r'width',
type: IsarType.int, type: IsarType.int,
) )
@@ -210,6 +215,12 @@ int _assetEstimateSize(
bytesCount += 3 + value.length * 3; bytesCount += 3 + value.length * 3;
} }
} }
{
final value = object.thumbhash;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount; return bytesCount;
} }
@@ -236,9 +247,10 @@ void _assetSerialize(
writer.writeString(offsets[14], object.remoteId); writer.writeString(offsets[14], object.remoteId);
writer.writeLong(offsets[15], object.stackCount); writer.writeLong(offsets[15], object.stackCount);
writer.writeString(offsets[16], object.stackParentId); writer.writeString(offsets[16], object.stackParentId);
writer.writeByte(offsets[17], object.type.index); writer.writeString(offsets[17], object.thumbhash);
writer.writeDateTime(offsets[18], object.updatedAt); writer.writeByte(offsets[18], object.type.index);
writer.writeInt(offsets[19], object.width); writer.writeDateTime(offsets[19], object.updatedAt);
writer.writeInt(offsets[20], object.width);
} }
Asset _assetDeserialize( Asset _assetDeserialize(
@@ -266,10 +278,11 @@ Asset _assetDeserialize(
remoteId: reader.readStringOrNull(offsets[14]), remoteId: reader.readStringOrNull(offsets[14]),
stackCount: reader.readLongOrNull(offsets[15]), stackCount: reader.readLongOrNull(offsets[15]),
stackParentId: reader.readStringOrNull(offsets[16]), stackParentId: reader.readStringOrNull(offsets[16]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? thumbhash: reader.readStringOrNull(offsets[17]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
AssetType.other, AssetType.other,
updatedAt: reader.readDateTime(offsets[18]), updatedAt: reader.readDateTime(offsets[19]),
width: reader.readIntOrNull(offsets[19]), width: reader.readIntOrNull(offsets[20]),
); );
return object; return object;
} }
@@ -316,11 +329,13 @@ P _assetDeserializeProp<P>(
case 16: case 16:
return (reader.readStringOrNull(offset)) as P; return (reader.readStringOrNull(offset)) as P;
case 17: case 17:
return (reader.readStringOrNull(offset)) as P;
case 18:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P; AssetType.other) as P;
case 18:
return (reader.readDateTime(offset)) as P;
case 19: case 19:
return (reader.readDateTime(offset)) as P;
case 20:
return (reader.readIntOrNull(offset)) as P; return (reader.readIntOrNull(offset)) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); 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( QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
AssetType value) { AssetType value) {
return QueryBuilder.apply(this, (query) { 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() { QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc); 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() { QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc); 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() { QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'type'); 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() { QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'type'); return query.addPropertyName(r'type');
@@ -9,6 +9,7 @@ part 'logger_message.model.g.dart';
class LoggerMessage { class LoggerMessage {
Id id = Isar.autoIncrement; Id id = Isar.autoIncrement;
String message; String message;
String? details;
@Enumerated(EnumType.ordinal) @Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO; LogLevel level = LogLevel.INFO;
DateTime createdAt; DateTime createdAt;
@@ -17,6 +18,7 @@ class LoggerMessage {
LoggerMessage({ LoggerMessage({
required this.message, required this.message,
required this.details,
required this.level, required this.level,
required this.createdAt, required this.createdAt,
required this.context1, required this.context1,
+213 -7
View File
@@ -32,14 +32,19 @@ const LoggerMessageSchema = CollectionSchema(
name: r'createdAt', name: r'createdAt',
type: IsarType.dateTime, type: IsarType.dateTime,
), ),
r'level': PropertySchema( r'details': PropertySchema(
id: 3, id: 3,
name: r'details',
type: IsarType.string,
),
r'level': PropertySchema(
id: 4,
name: r'level', name: r'level',
type: IsarType.byte, type: IsarType.byte,
enumMap: _LoggerMessagelevelEnumValueMap, enumMap: _LoggerMessagelevelEnumValueMap,
), ),
r'message': PropertySchema( r'message': PropertySchema(
id: 4, id: 5,
name: r'message', name: r'message',
type: IsarType.string, type: IsarType.string,
) )
@@ -76,6 +81,12 @@ int _loggerMessageEstimateSize(
bytesCount += 3 + value.length * 3; bytesCount += 3 + value.length * 3;
} }
} }
{
final value = object.details;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
bytesCount += 3 + object.message.length * 3; bytesCount += 3 + object.message.length * 3;
return bytesCount; return bytesCount;
} }
@@ -89,8 +100,9 @@ void _loggerMessageSerialize(
writer.writeString(offsets[0], object.context1); writer.writeString(offsets[0], object.context1);
writer.writeString(offsets[1], object.context2); writer.writeString(offsets[1], object.context2);
writer.writeDateTime(offsets[2], object.createdAt); writer.writeDateTime(offsets[2], object.createdAt);
writer.writeByte(offsets[3], object.level.index); writer.writeString(offsets[3], object.details);
writer.writeString(offsets[4], object.message); writer.writeByte(offsets[4], object.level.index);
writer.writeString(offsets[5], object.message);
} }
LoggerMessage _loggerMessageDeserialize( LoggerMessage _loggerMessageDeserialize(
@@ -103,9 +115,10 @@ LoggerMessage _loggerMessageDeserialize(
context1: reader.readStringOrNull(offsets[0]), context1: reader.readStringOrNull(offsets[0]),
context2: reader.readStringOrNull(offsets[1]), context2: reader.readStringOrNull(offsets[1]),
createdAt: reader.readDateTime(offsets[2]), createdAt: reader.readDateTime(offsets[2]),
level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[3])] ?? details: reader.readStringOrNull(offsets[3]),
level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ??
LogLevel.ALL, LogLevel.ALL,
message: reader.readString(offsets[4]), message: reader.readString(offsets[5]),
); );
object.id = id; object.id = id;
return object; return object;
@@ -125,9 +138,11 @@ P _loggerMessageDeserializeProp<P>(
case 2: case 2:
return (reader.readDateTime(offset)) as P; return (reader.readDateTime(offset)) as P;
case 3: case 3:
return (reader.readStringOrNull(offset)) as P;
case 4:
return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ?? return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ??
LogLevel.ALL) as P; LogLevel.ALL) as P;
case 4: case 5:
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); 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( QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition> idEqualTo(
Id value) { Id value) {
return QueryBuilder.apply(this, (query) { 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() { QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByLevel() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'level', Sort.asc); 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() { QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc); 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() { QueryBuilder<LoggerMessage, LoggerMessage, QDistinct> distinctByLevel() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'level'); 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() { QueryBuilder<LoggerMessage, LogLevel, QQueryOperations> levelProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'level'); return query.addPropertyName(r'level');
@@ -90,7 +90,7 @@ class AssetService {
return allAssets; return allAssets;
} catch (error, stack) { } catch (error, stack) {
log.severe( log.severe(
'Error while getting remote assets: ${error.toString()}', 'Error while getting remote assets',
error, error,
stack, stack,
); );
@@ -117,7 +117,7 @@ class AssetService {
); );
return true; return true;
} catch (error, stack) { } catch (error, stack) {
log.severe("Error deleteAssets ${error.toString()}", error, stack); log.severe("Error while deleting assets", error, stack);
} }
return false; return false;
} }
@@ -12,7 +12,7 @@ import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package. /// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to the database and onto console, using `debugPrint` method. /// The logs are written to the database and onto console, using `debugPrint` method.
/// ///
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property /// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
/// in the class. /// in the class.
/// ///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
@@ -58,6 +58,7 @@ class ImmichLogger {
debugPrint('[${record.level.name}] [${record.time}] ${record.message}'); debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
final lm = LoggerMessage( final lm = LoggerMessage(
message: record.message, message: record.message,
details: record.error?.toString(),
level: record.level.toLogLevel(), level: record.level.toLogLevel(),
createdAt: record.time, createdAt: record.time,
context1: record.loggerName, context1: record.loggerName,
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -41,7 +42,8 @@ class ShareService {
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe( _log.severe(
"Asset download failed with status - ${res.statusCode} and response - ${res.body}", "Asset download for ${asset.fileName} failed",
res.toLoggerString(),
); );
continue; continue;
} }
@@ -68,7 +70,7 @@ class ShareService {
); );
return true; return true;
} catch (error) { } catch (error) {
_log.severe("Share failed with error $error"); _log.severe("Share failed", error);
} }
return false; return false;
} }
+10 -12
View File
@@ -140,7 +140,7 @@ class SyncService {
try { try {
await _db.writeTxn(() => a.put(_db)); await _db.writeTxn(() => a.put(_db));
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to put new asset into db: $e"); _log.severe("Failed to put new asset into db", e);
return false; return false;
} }
return true; return true;
@@ -173,7 +173,7 @@ class SyncService {
} }
return false; return false;
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e"); _log.severe("Failed to sync remote assets to db", e);
} }
return null; return null;
} }
@@ -232,7 +232,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await upsertAssetsWithExif(toAdd + toUpdate); await upsertAssetsWithExif(toAdd + toUpdate);
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e"); _log.severe("Failed to sync remote assets to db", e);
} }
await _updateUserAssetsETag(user, now); await _updateUserAssetsETag(user, now);
return true; return true;
@@ -364,7 +364,7 @@ class SyncService {
}); });
_log.info("Synced changes of remote album ${album.name} to DB"); _log.info("Synced changes of remote album ${album.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to sync remote album to database $e"); _log.severe("Failed to sync remote album to database", e);
} }
if (album.shared || dto.shared) { if (album.shared || dto.shared) {
@@ -441,7 +441,7 @@ class SyncService {
assert(ok); assert(ok);
_log.info("Removed local album $album from DB"); _log.info("Removed local album $album from DB");
} catch (e) { } catch (e) {
_log.severe("Failed to remove local album $album from DB"); _log.severe("Failed to remove local album $album from DB", e);
} }
} }
@@ -577,7 +577,7 @@ class SyncService {
}); });
_log.info("Synced changes of local album ${ape.name} to DB"); _log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to update synced album ${ape.name} in DB: $e"); _log.severe("Failed to update synced album ${ape.name} in DB", e);
} }
return true; return true;
@@ -623,7 +623,7 @@ class SyncService {
}); });
_log.info("Fast synced local album ${ape.name} to DB"); _log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to fast sync local album ${ape.name} to DB: $e"); _log.severe("Failed to fast sync local album ${ape.name} to DB", e);
return false; return false;
} }
@@ -656,7 +656,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}"); _log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to add new local album ${ape.name} to DB: $e"); _log.severe("Failed to add new local album ${ape.name} to DB", e);
} }
} }
@@ -706,9 +706,7 @@ class SyncService {
}); });
_log.info("Upserted ${assets.length} assets into the DB"); _log.info("Upserted ${assets.length} assets into the DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe( _log.severe("Failed to upsert ${assets.length} assets into the DB", e);
"Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
);
// give details on the errors // give details on the errors
assets.sort(Asset.compareByOwnerChecksum); assets.sort(Asset.compareByOwnerChecksum);
final inDb = await _db.assets.getAllByOwnerIdChecksum( final inDb = await _db.assets.getAllByOwnerIdChecksum(
@@ -776,7 +774,7 @@ class SyncService {
}); });
return true; return true;
} catch (e) { } catch (e) {
_log.severe("Failed to remove all local albums and assets: $e"); _log.severe("Failed to remove all local albums and assets", e);
return false; return false;
} }
} }
+2 -2
View File
@@ -42,7 +42,7 @@ class UserService {
final dto = await _apiService.userApi.getAllUsers(isAll); final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromUserDto).toList(); return dto?.map(User.fromUserDto).toList();
} catch (e) { } catch (e) {
_log.warning("Failed get all users:\n$e"); _log.warning("Failed get all users", e);
return null; return null;
} }
} }
@@ -65,7 +65,7 @@ class UserService {
), ),
); );
} catch (e) { } catch (e) {
_log.warning("Failed to upload profile image:\n$e"); _log.warning("Failed to upload profile image", e);
return null; return null;
} }
} }
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class DelayedLoadingIndicator extends StatelessWidget {
/// The delay to avoid showing the loading indicator
final Duration delay;
/// Defaults to using the [ImmichLoadingIndicator]
final Widget? child;
/// An optional fade in duration to animate the loading
final Duration? fadeInDuration;
const DelayedLoadingIndicator({
super.key,
this.delay = const Duration(seconds: 3),
this.child,
this.fadeInDuration,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: fadeInDuration ?? Duration.zero,
child: FutureBuilder(
future: Future.delayed(delay),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return child ??
const ImmichLoadingIndicator(
key: ValueKey('loading'),
);
}
return Container(key: const ValueKey('hiding'));
},
),
);
}
}
@@ -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),
),
],
),
);
}
}
@@ -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));
}
+8 -53
View File
@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:octo_image/octo_image.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 { class ImmichImage extends StatelessWidget {
const ImmichImage( const ImmichImage(
@@ -19,8 +15,6 @@ class ImmichImage extends StatelessWidget {
this.height, this.height,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(), this.placeholder = const ThumbnailPlaceholder(),
this.isThumbnail = false,
this.thumbnailSize = 250,
super.key, super.key,
}); });
@@ -29,32 +23,6 @@ class ImmichImage extends StatelessWidget {
final double? width; final double? width;
final double? height; final double? height;
final BoxFit fit; 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 // Helper function to return the image provider for the asset
// either by using the asset ID or the asset itself // either by using the asset ID or the asset itself
@@ -66,8 +34,6 @@ class ImmichImage extends StatelessWidget {
static ImageProvider imageProvider({ static ImageProvider imageProvider({
Asset? asset, Asset? asset,
String? assetId, String? assetId,
bool isThumbnail = false,
int thumbnailSize = 250,
}) { }) {
if (asset == null && assetId == null) { if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId'); throw Exception('Must supply either asset or assetId');
@@ -76,24 +42,18 @@ class ImmichImage extends StatelessWidget {
if (asset == null) { if (asset == null) {
return ImmichRemoteImageProvider( return ImmichRemoteImageProvider(
assetId: assetId!, assetId: assetId!,
isThumbnail: isThumbnail, isThumbnail: false,
); );
} }
if (useLocal(asset) && isThumbnail) { if (useLocal(asset)) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: ThumbnailSize.square(thumbnailSize),
);
} else if (useLocal(asset) && !isThumbnail) {
return ImmichLocalImageProvider( return ImmichLocalImageProvider(
asset: asset, asset: asset,
); );
} else { } else {
return ImmichRemoteImageProvider( return ImmichRemoteImageProvider(
assetId: asset.remoteId!, assetId: asset.remoteId!,
isThumbnail: isThumbnail, isThumbnail: false,
); );
} }
} }
@@ -105,15 +65,11 @@ class ImmichImage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (asset == null) { if (asset == null) {
return Container( return Container(
decoration: const BoxDecoration( color: Colors.grey,
color: Colors.grey, width: width,
), height: height,
child: SizedBox( child: const Center(
width: width, child: Icon(Icons.no_photography),
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
), ),
); );
} }
@@ -131,7 +87,6 @@ class ImmichImage extends StatelessWidget {
}, },
image: ImmichImage.imageProvider( image: ImmichImage.imageProvider(
asset: asset, asset: asset,
isThumbnail: isThumbnail,
), ),
width: width, width: width,
height: height, height: height,
@@ -0,0 +1,89 @@
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/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 (useLocal(asset)) {
return ImmichLocalThumbnailProvider(
asset: asset,
height: thumbnailSize,
width: thumbnailSize,
);
} else {
return ImmichRemoteImageProvider(
assetId: asset.remoteId!,
isThumbnail: true,
);
}
}
static bool useLocal(Asset asset) => !asset.isRemote || asset.isLocal;
@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,
);
}
}
@@ -0,0 +1,47 @@
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()
: Image(
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) { Widget build(BuildContext context, WidgetRef ref) {
var isDarkTheme = context.isDarkTheme; var isDarkTheme = context.isDarkTheme;
buildStackMessage(String stackTrace) { buildTextWithCopyButton(String header, String text) {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
@@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.only(bottom: 8.0),
child: Text( child: Text(
"STACK TRACES", header,
style: TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
color: context.primaryColor, color: context.primaryColor,
@@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget {
), ),
IconButton( IconButton(
onPressed: () { onPressed: () {
Clipboard.setData(ClipboardData(text: stackTrace)) Clipboard.setData(ClipboardData(text: text)).then((_) {
.then((_) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: SelectableText( child: SelectableText(
stackTrace, text,
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
),
),
),
),
],
),
);
}
buildLogMessage(String message) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"MESSAGE",
style: TextStyle(
fontSize: 12.0,
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: message)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Copied to clipboard",
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
),
),
);
});
},
icon: Icon(
Icons.copy,
size: 16.0,
color: context.primaryColor,
),
),
],
),
Container(
decoration: BoxDecoration(
color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
borderRadius: BorderRadius.circular(15.0),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SelectableText(
message,
style: const TextStyle( style: const TextStyle(
fontSize: 12.0, fontSize: 12.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget {
body: SafeArea( body: SafeArea(
child: ListView( child: ListView(
children: [ children: [
buildLogMessage(logMessage.message), buildTextWithCopyButton("MESSAGE", logMessage.message),
if (logMessage.details != null)
buildTextWithCopyButton("DETAILS", logMessage.details.toString()),
if (logMessage.context1 != null) if (logMessage.context1 != null)
buildLogContext1(logMessage.context1.toString()), buildLogContext1(logMessage.context1.toString()),
if (logMessage.context2 != null) if (logMessage.context2 != null)
buildStackMessage(logMessage.context2.toString()), buildTextWithCopyButton(
"STACK TRACE",
logMessage.context2.toString(),
),
], ],
), ),
), ),
+9 -23
View File
@@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: const Text(
"Logs - ${logMessages.value.length}", "Logs",
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16.0, fontSize: 16.0,
), ),
@@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget {
dense: true, dense: true,
tileColor: getTileColor(logMessage.level), tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10, minLeadingWidth: 10,
title: Text.rich( title: Text(
TextSpan( truncateLogMessage(logMessage.message, 4),
children: [ style: const TextStyle(
TextSpan( fontSize: 14.0,
text: "#$index ", fontFamily: "Inconsolata",
style: TextStyle(
color: isDarkTheme ? Colors.white70 : Colors.grey[600],
fontSize: 14.0,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: truncateLogMessage(logMessage.message, 4),
style: const TextStyle(
fontSize: 14.0,
),
),
],
), ),
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
), ),
subtitle: Text( subtitle: Text(
"[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}", "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}",
style: TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
color: Colors.grey[600], color: Colors.grey[600],
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
final _loadingEntry = OverlayEntry( final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square( builder: (context) => SizedBox.square(
@@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox( child: DecoratedBox(
decoration: decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)), BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
child: const Center(child: ImmichLoadingIndicator()), child: const Center(
child: DelayedLoadingIndicator(
delay: Duration(seconds: 1),
fadeInDuration: Duration(milliseconds: 400),
),
),
), ),
), ),
); );
@@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
class _LoadingOverlayState class _LoadingOverlayState
extends HookState<ValueNotifier<bool>, _LoadingOverlay> { extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
late final _isProcessing = ValueNotifier(false)..addListener(_listener); late final _isLoading = ValueNotifier(false)..addListener(_listener);
OverlayEntry? overlayEntry; OverlayEntry? _loadingOverlay;
void _listener() { void _listener() {
setState(() { setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isProcessing.value) { if (_isLoading.value) {
overlayEntry?.remove(); _loadingOverlay?.remove();
overlayEntry = _loadingEntry; _loadingOverlay = _loadingEntry;
Overlay.of(context).insert(_loadingEntry); Overlay.of(context).insert(_loadingEntry);
} else { } else {
overlayEntry?.remove(); _loadingOverlay?.remove();
overlayEntry = null; _loadingOverlay = null;
} }
}); });
}); });
@@ -47,17 +52,17 @@ class _LoadingOverlayState
@override @override
ValueNotifier<bool> build(BuildContext context) { ValueNotifier<bool> build(BuildContext context) {
return _isProcessing; return _isLoading;
} }
@override @override
void dispose() { void dispose() {
_isProcessing.dispose(); _isLoading.dispose();
super.dispose(); super.dispose();
} }
@override @override
Object? get debugValue => _isProcessing.value; Object? get debugValue => _isLoading.value;
@override @override
String get debugLabel => 'useProcessingOverlay<>'; String get debugLabel => 'useProcessingOverlay<>';
+3 -3
View File
@@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget {
deviceIsOffline = true; deviceIsOffline = true;
log.fine("Device seems to be offline upon launch"); log.fine("Device seems to be offline upon launch");
} else { } else {
log.severe(e); log.severe("Failed to resolve endpoint", e);
} }
} catch (e) { } catch (e) {
log.severe(e); log.severe("Failed to resolve endpoint", e);
} }
try { try {
@@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget {
ref.read(authenticationProvider.notifier).logout(); ref.read(authenticationProvider.notifier).logout();
log.severe( log.severe(
'Cannot set success login info: $error', 'Cannot set success login info',
error, error,
stackTrace, stackTrace,
); );
+3
View File
@@ -108,6 +108,7 @@ doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md doc/PersonUpdateDto.md
doc/PersonWithFacesResponseDto.md doc/PersonWithFacesResponseDto.md
doc/PlacesResponseDto.md
doc/QueueStatusDto.md doc/QueueStatusDto.md
doc/ReactionLevel.md doc/ReactionLevel.md
doc/ReactionType.md doc/ReactionType.md
@@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart lib/model/person_update_dto.dart
lib/model/person_with_faces_response_dto.dart lib/model/person_with_faces_response_dto.dart
lib/model/places_response_dto.dart
lib/model/queue_status_dto.dart lib/model/queue_status_dto.dart
lib/model/reaction_level.dart lib/model/reaction_level.dart
lib/model/reaction_type.dart lib/model/reaction_type.dart
@@ -485,6 +487,7 @@ test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart test/person_update_dto_test.dart
test/person_with_faces_response_dto_test.dart test/person_with_faces_response_dto_test.dart
test/places_response_dto_test.dart
test/queue_status_dto_test.dart test/queue_status_dto_test.dart
test/reaction_level_test.dart test/reaction_level_test.dart
test/reaction_type_test.dart test/reaction_type_test.dart
+2
View File
@@ -166,6 +166,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *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 | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
@@ -306,6 +307,7 @@ Class | Method | HTTP request | Description
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md) - [QueueStatusDto](doc//QueueStatusDto.md)
- [ReactionLevel](doc//ReactionLevel.md) - [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md) - [ReactionType](doc//ReactionType.md)
+1
View File
@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
## Properties ## Properties
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**hidden** | **int** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [default to const []] **people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [default to const []]
**total** | **int** | | **total** | **int** | |
+19
View 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)
+56
View File
@@ -14,6 +14,7 @@ Method | HTTP request | Description
[**search**](SearchApi.md#search) | **GET** /search | [**search**](SearchApi.md#search) | **GET** /search |
[**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata | [**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata |
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person | [**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
[**searchPlaces**](SearchApi.md#searchplaces) | **GET** /search/places |
[**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart | [**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart |
@@ -316,6 +317,61 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **searchPlaces**
> List<PlacesResponseDto> searchPlaces(name)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SearchApi();
final name = name_example; // String |
try {
final result = api_instance.searchPlaces(name);
print(result);
} catch (e) {
print('Exception when calling SearchApi->searchPlaces: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**name** | **String**| |
### Return type
[**List<PlacesResponseDto>**](PlacesResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **searchSmart** # **searchSmart**
> SearchResponseDto searchSmart(smartSearchDto) > SearchResponseDto searchSmart(smartSearchDto)
+1
View File
@@ -142,6 +142,7 @@ part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart'; part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart'; part 'model/person_update_dto.dart';
part 'model/person_with_faces_response_dto.dart'; part 'model/person_with_faces_response_dto.dart';
part 'model/places_response_dto.dart';
part 'model/queue_status_dto.dart'; part 'model/queue_status_dto.dart';
part 'model/reaction_level.dart'; part 'model/reaction_level.dart';
part 'model/reaction_type.dart'; part 'model/reaction_type.dart';
+52
View File
@@ -360,6 +360,58 @@ class SearchApi {
return null; return null;
} }
/// Performs an HTTP 'GET /search/places' operation and returns the [Response].
/// Parameters:
///
/// * [String] name (required):
Future<Response> searchPlacesWithHttpInfo(String name,) async {
// ignore: prefer_const_declarations
final path = r'/search/places';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'name', name));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] name (required):
Future<List<PlacesResponseDto>?> searchPlaces(String name,) async {
final response = await searchPlacesWithHttpInfo(name,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PlacesResponseDto>') as List)
.cast<PlacesResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
+2
View File
@@ -366,6 +366,8 @@ class ApiClient {
return PersonUpdateDto.fromJson(value); return PersonUpdateDto.fromJson(value);
case 'PersonWithFacesResponseDto': case 'PersonWithFacesResponseDto':
return PersonWithFacesResponseDto.fromJson(value); return PersonWithFacesResponseDto.fromJson(value);
case 'PlacesResponseDto':
return PlacesResponseDto.fromJson(value);
case 'QueueStatusDto': case 'QueueStatusDto':
return QueueStatusDto.fromJson(value); return QueueStatusDto.fromJson(value);
case 'ReactionLevel': case 'ReactionLevel':
+9 -1
View File
@@ -13,30 +13,36 @@ part of openapi.api;
class PeopleResponseDto { class PeopleResponseDto {
/// Returns a new [PeopleResponseDto] instance. /// Returns a new [PeopleResponseDto] instance.
PeopleResponseDto({ PeopleResponseDto({
required this.hidden,
this.people = const [], this.people = const [],
required this.total, required this.total,
}); });
int hidden;
List<PersonResponseDto> people; List<PersonResponseDto> people;
int total; int total;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto && bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto &&
other.hidden == hidden &&
_deepEquality.equals(other.people, people) && _deepEquality.equals(other.people, people) &&
other.total == total; other.total == total;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(hidden.hashCode) +
(people.hashCode) + (people.hashCode) +
(total.hashCode); (total.hashCode);
@override @override
String toString() => 'PeopleResponseDto[people=$people, total=$total]'; String toString() => 'PeopleResponseDto[hidden=$hidden, people=$people, total=$total]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'hidden'] = this.hidden;
json[r'people'] = this.people; json[r'people'] = this.people;
json[r'total'] = this.total; json[r'total'] = this.total;
return json; return json;
@@ -50,6 +56,7 @@ class PeopleResponseDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return PeopleResponseDto( return PeopleResponseDto(
hidden: mapValueOfType<int>(json, r'hidden')!,
people: PersonResponseDto.listFromJson(json[r'people']), people: PersonResponseDto.listFromJson(json[r'people']),
total: mapValueOfType<int>(json, r'total')!, total: mapValueOfType<int>(json, r'total')!,
); );
@@ -99,6 +106,7 @@ class PeopleResponseDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'hidden',
'people', 'people',
'total', 'total',
}; };
+148
View File
@@ -0,0 +1,148 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PlacesResponseDto {
/// Returns a new [PlacesResponseDto] instance.
PlacesResponseDto({
this.admin1name,
this.admin2name,
required this.latitude,
required this.longitude,
required this.name,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? admin1name;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? admin2name;
num latitude;
num longitude;
String name;
@override
bool operator ==(Object other) => identical(this, other) || other is PlacesResponseDto &&
other.admin1name == admin1name &&
other.admin2name == admin2name &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(admin1name == null ? 0 : admin1name!.hashCode) +
(admin2name == null ? 0 : admin2name!.hashCode) +
(latitude.hashCode) +
(longitude.hashCode) +
(name.hashCode);
@override
String toString() => 'PlacesResponseDto[admin1name=$admin1name, admin2name=$admin2name, latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.admin1name != null) {
json[r'admin1name'] = this.admin1name;
} else {
// json[r'admin1name'] = null;
}
if (this.admin2name != null) {
json[r'admin2name'] = this.admin2name;
} else {
// json[r'admin2name'] = null;
}
json[r'latitude'] = this.latitude;
json[r'longitude'] = this.longitude;
json[r'name'] = this.name;
return json;
}
/// Returns a new [PlacesResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PlacesResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PlacesResponseDto(
admin1name: mapValueOfType<String>(json, r'admin1name'),
admin2name: mapValueOfType<String>(json, r'admin2name'),
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name')!,
);
}
return null;
}
static List<PlacesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PlacesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PlacesResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PlacesResponseDto> mapFromJson(dynamic json) {
final map = <String, PlacesResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PlacesResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PlacesResponseDto-objects as value to a dart map
static Map<String, List<PlacesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PlacesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PlacesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'latitude',
'longitude',
'name',
};
}
+5
View File
@@ -16,6 +16,11 @@ void main() {
// final instance = PeopleResponseDto(); // final instance = PeopleResponseDto();
group('test PeopleResponseDto', () { group('test PeopleResponseDto', () {
// int hidden
test('to test the property `hidden`', () async {
// TODO
});
// List<PersonResponseDto> people (default value: const []) // List<PersonResponseDto> people (default value: const [])
test('to test the property `people`', () async { test('to test the property `people`', () async {
// TODO // TODO
+47
View File
@@ -0,0 +1,47 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for PlacesResponseDto
void main() {
// final instance = PlacesResponseDto();
group('test PlacesResponseDto', () {
// String admin1name
test('to test the property `admin1name`', () async {
// TODO
});
// String admin2name
test('to test the property `admin2name`', () async {
// TODO
});
// num latitude
test('to test the property `latitude`', () async {
// TODO
});
// num longitude
test('to test the property `longitude`', () async {
// TODO
});
// String name
test('to test the property `name`', () async {
// TODO
});
});
}
+5
View File
@@ -42,6 +42,11 @@ void main() {
// TODO // TODO
}); });
//Future<List<PlacesResponseDto>> searchPlaces(String name) async
test('test searchPlaces', () async {
// TODO
});
//Future<SearchResponseDto> searchSmart(SmartSearchDto smartSearchDto) async //Future<SearchResponseDto> searchSmart(SmartSearchDto smartSearchDto) async
test('test searchSmart', () async { test('test searchSmart', () async {
// TODO // TODO
+18 -10
View File
@@ -569,10 +569,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_udid name: flutter_udid
sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84" sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "3.0.0"
flutter_web_auth: flutter_web_auth:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -619,10 +619,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: geolocator name: geolocator
sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02 sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.1.0" version: "11.0.0"
geolocator_android: geolocator_android:
dependency: transitive dependency: transitive
description: description:
@@ -651,10 +651,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_web name: geolocator_web
sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58" sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "3.0.0"
geolocator_windows: geolocator_windows:
dependency: transitive dependency: transitive
description: description:
@@ -1298,10 +1298,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_linux name: shared_preferences_linux
sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.2"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1322,10 +1322,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_windows name: shared_preferences_windows
sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.2"
shelf: shelf:
dependency: transitive dependency: transitive
description: description:
@@ -1467,6 +1467,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.1" version: "0.6.1"
thumbhash:
dependency: "direct main"
description:
name: thumbhash
sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
time: time:
dependency: transitive dependency: transitive
description: description:
+3 -2
View File
@@ -32,8 +32,8 @@ dependencies:
git: git:
url: https://github.com/maplibre/flutter-maplibre-gl.git url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
geolocator: ^10.1.0 # used to move to current location in map view geolocator: ^11.0.0 # used to move to current location in map view
flutter_udid: ^2.1.1 flutter_udid: ^3.0.0
package_info_plus: ^5.0.1 package_info_plus: ^5.0.1
url_launcher: ^6.2.4 url_launcher: ^6.2.4
http: 0.13.5 http: 0.13.5
@@ -57,6 +57,7 @@ dependencies:
flutter_local_notifications: ^16.3.2 flutter_local_notifications: ^16.3.2
timezone: ^0.9.2 timezone: ^0.9.2
octo_image: ^2.0.0 octo_image: ^2.0.0
thumbhash: 0.1.0+1
openapi: openapi:
path: openapi path: openapi
+73
View File
@@ -4691,6 +4691,50 @@
] ]
} }
}, },
"/search/places": {
"get": {
"operationId": "searchPlaces",
"parameters": [
{
"name": "name",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PlacesResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Search"
]
}
},
"/search/smart": { "/search/smart": {
"post": { "post": {
"operationId": "searchSmart", "operationId": "searchSmart",
@@ -8593,6 +8637,9 @@
}, },
"PeopleResponseDto": { "PeopleResponseDto": {
"properties": { "properties": {
"hidden": {
"type": "integer"
},
"people": { "people": {
"items": { "items": {
"$ref": "#/components/schemas/PersonResponseDto" "$ref": "#/components/schemas/PersonResponseDto"
@@ -8604,6 +8651,7 @@
} }
}, },
"required": [ "required": [
"hidden",
"people", "people",
"total" "total"
], ],
@@ -8752,6 +8800,31 @@
], ],
"type": "object" "type": "object"
}, },
"PlacesResponseDto": {
"properties": {
"admin1name": {
"type": "string"
},
"admin2name": {
"type": "string"
},
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"name": {
"type": "string"
}
},
"required": [
"latitude",
"longitude",
"name"
],
"type": "object"
},
"QueueStatusDto": { "QueueStatusDto": {
"properties": { "properties": {
"isActive": { "isActive": {
+134
View File
@@ -2801,6 +2801,12 @@ export type PathType = typeof PathType[keyof typeof PathType];
* @interface PeopleResponseDto * @interface PeopleResponseDto
*/ */
export interface PeopleResponseDto { export interface PeopleResponseDto {
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'hidden': number;
/** /**
* *
* @type {Array<PersonResponseDto>} * @type {Array<PersonResponseDto>}
@@ -2988,6 +2994,43 @@ export interface PersonWithFacesResponseDto {
*/ */
'thumbnailPath': string; 'thumbnailPath': string;
} }
/**
*
* @export
* @interface PlacesResponseDto
*/
export interface PlacesResponseDto {
/**
*
* @type {string}
* @memberof PlacesResponseDto
*/
'admin1name'?: string;
/**
*
* @type {string}
* @memberof PlacesResponseDto
*/
'admin2name'?: string;
/**
*
* @type {number}
* @memberof PlacesResponseDto
*/
'latitude': number;
/**
*
* @type {number}
* @memberof PlacesResponseDto
*/
'longitude': number;
/**
*
* @type {string}
* @memberof PlacesResponseDto
*/
'name': string;
}
/** /**
* *
* @export * @export
@@ -15441,6 +15484,51 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPlaces: async (name: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('searchPlaces', 'name', name)
const localVarPath = `/search/places`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -15578,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url; const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
}, },
/**
*
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async searchPlaces(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PlacesResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPlaces(name, options);
const index = configuration?.serverIndex ?? 0;
const operationBasePath = operationServerMap['SearchApi.searchPlaces']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
/** /**
* *
* @param {SmartSearchDto} smartSearchDto * @param {SmartSearchDto} smartSearchDto
@@ -15645,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PlacesResponseDto>> {
return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters. * @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
@@ -15811,6 +15920,20 @@ export interface SearchApiSearchPersonRequest {
readonly withHidden?: boolean readonly withHidden?: boolean
} }
/**
* Request parameters for searchPlaces operation in SearchApi.
* @export
* @interface SearchApiSearchPlacesRequest
*/
export interface SearchApiSearchPlacesRequest {
/**
*
* @type {string}
* @memberof SearchApiSearchPlaces
*/
readonly name: string
}
/** /**
* Request parameters for searchSmart operation in SearchApi. * Request parameters for searchSmart operation in SearchApi.
* @export * @export
@@ -15887,6 +16010,17 @@ export class SearchApi extends BaseAPI {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig) {
return SearchApiFp(this.configuration).searchPlaces(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters. * @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
+20
View File
@@ -524,6 +524,7 @@ export type UpdatePartnerDto = {
inTimeline: boolean; inTimeline: boolean;
}; };
export type PeopleResponseDto = { export type PeopleResponseDto = {
hidden: number;
people: PersonResponseDto[]; people: PersonResponseDto[];
total: number; total: number;
}; };
@@ -645,6 +646,13 @@ export type MetadataSearchDto = {
withPeople?: boolean; withPeople?: boolean;
withStacked?: boolean; withStacked?: boolean;
}; };
export type PlacesResponseDto = {
admin1name?: string;
admin2name?: string;
latitude: number;
longitude: number;
name: string;
};
export type SmartSearchDto = { export type SmartSearchDto = {
city?: string; city?: string;
country?: string; country?: string;
@@ -2197,6 +2205,18 @@ export function searchPerson({ name, withHidden }: {
...opts ...opts
})); }));
} }
export function searchPlaces({ name }: {
name: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PlacesResponseDto[];
}>(`/search/places${QS.query(QS.explode({
name
}))}`, {
...opts
}));
}
export function searchSmart({ smartSearchDto }: { export function searchSmart({ smartSearchDto }: {
smartSearchDto: SmartSearchDto; smartSearchDto: SmartSearchDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
+15
View File
@@ -0,0 +1,15 @@
import { HttpError } from '@oazapfts/runtime';
export interface ApiExceptionResponse {
message: string;
error?: string;
statusCode: number;
}
export interface ApiHttpError extends HttpError {
data: ApiExceptionResponse;
}
export function isHttpError(error: unknown): error is ApiHttpError {
return error instanceof HttpError;
}
+1
View File
@@ -1 +1,2 @@
export * from './fetch-client'; export * from './fetch-client';
export * from './fetch-errors';
+3 -3
View File
@@ -29,9 +29,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.17", "version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
+2 -2
View File
@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:20240213@sha256:16646a37bae065b51e68cb2ba7a63027b29504d43a30644625382afbe326114a as dev FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev
RUN apt-get install --no-install-recommends -yqq tini RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -40,7 +40,7 @@ RUN npm run build
# prod build # prod build
FROM ghcr.io/immich-app/base-server-prod:20240213@sha256:61d159d069c5b522f16de9733fb79feb0e82c0b099d16f026196f344d12a1e5e FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
+3 -3
View File
@@ -531,8 +531,8 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body.length).toBe(assets.length); expect(body.length).toBe(assets.length);
for (let i = 0; i < assets.length; i++) { for (const [i, asset] of assets.entries()) {
expect(body[i]).toEqual(expect.objectContaining({ id: assets[i].id })); expect(body[i]).toEqual(expect.objectContaining({ id: asset.id }));
} }
}); });
} }
@@ -699,7 +699,7 @@ describe(`${AssetController.name} (e2e)`, () => {
it("should not upload to another user's library", async () => { it("should not upload to another user's library", async () => {
const content = randomBytes(32); const content = randomBytes(32);
const library = (await api.libraryApi.getAll(server, user2.accessToken))[0]; const [library] = await api.libraryApi.getAll(server, user2.accessToken);
await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
const { body, status } = await request(server) const { body, status } = await request(server)
-191
View File
@@ -1,191 +0,0 @@
import { IPersonRepository, LoginResponseDto } from '@app/domain';
import { PersonController } from '@app/immich';
import { PersonEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { errorStub, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${PersonController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let personRepository: IPersonRepository;
let visiblePerson: PersonEntity;
let hiddenPerson: PersonEntity;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
personRepository = app.get<IPersonRepository>(IPersonRepository);
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
const faceAsset = await api.assetApi.upload(server, accessToken, 'face_asset');
visiblePerson = await personRepository.create({
ownerId: loginResponse.userId,
name: 'visible_person',
thumbnailPath: '/thumbnail/face_asset',
});
await personRepository.createFaces([
{
assetId: faceAsset.id,
personId: visiblePerson.id,
embedding: Array.from({ length: 512 }, Math.random),
},
]);
hiddenPerson = await personRepository.create({
ownerId: loginResponse.userId,
name: 'hidden_person',
isHidden: true,
thumbnailPath: '/thumbnail/face_asset',
});
await personRepository.createFaces([
{
assetId: faceAsset.id,
personId: hiddenPerson.id,
embedding: Array.from({ length: 512 }, Math.random),
},
]);
});
describe('GET /person', () => {
beforeEach(async () => {});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/person');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return all people (including hidden)', async () => {
const { status, body } = await request(server)
.get('/person')
.set('Authorization', `Bearer ${accessToken}`)
.query({ withHidden: true });
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
people: [
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }),
],
});
});
it('should return only visible people', async () => {
const { status, body } = await request(server).get('/person').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
});
describe('GET /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/person/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(server)
.get(`/person/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should return person information', async () => {
const { status, body } = await request(server)
.get(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
});
});
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const { key, type } of [
{ key: 'name', type: 'string' },
{ key: 'featureFaceAssetId', type: 'string' },
{ key: 'isHidden', type: 'boolean value' },
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest([`${key} must be a ${type}`]));
});
}
it('should not accept invalid birth dates', async () => {
for (const { birthDate, response } of [
{ birthDate: false, response: 'Not found or no person.write access' },
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
{ birthDate: '123567', response: 'Not found or no person.write access' },
{ birthDate: 123567, response: 'Not found or no person.write access' },
]) {
const { status, body } = await request(server)
.put(`/person/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(response));
}
});
it('should update a date of birth', async () => {
const { status, body } = await request(server)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: '1990-01-01T05:00:00.000Z' });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01' });
});
it('should clear a date of birth', async () => {
const person = await personRepository.create({
birthDate: new Date('1990-01-01'),
ownerId: loginResponse.userId,
});
expect(person.birthDate).toBeDefined();
const { status, body } = await request(server)
.put(`/person/${person.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: null });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: null });
});
});
});

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