1
0
forked from Cutlery/immich

Compare commits

...

69 Commits

Author SHA1 Message Date
mertalev c6a1d7b4f7 modularize model classes 2024-03-31 23:51:02 -04:00
mmomjian 245535ee04 docs: specify Timezone (#8403) 2024-03-31 11:38:16 -05:00
Mert 5bc9158724 fix(server): penalize null geodata fields when searching places (#8408) 2024-03-31 10:59:11 -04:00
Pablo Diz 6a4bc777a2 Fix external library path validation #8319 (#8366)
* Fix isImmichPath

* prettier write

* Fis isImmichPath code comment

* Refactor isImmichPath function based on team suggestions

* Test isImmichPath

* fix: clean comments

* Refactor isImmichPath test based on team suggestions

* Clean code with lintern suggestions
2024-03-31 10:47:03 -04:00
waclaw66 34cbb18ecd fix(mobile): memories translation (#8316) 2024-03-31 06:59:11 +00:00
renovate[bot] e2d5a8c0bb fix(deps): update machine-learning (#8280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-31 06:05:18 +00:00
mmomjian 94cd806675 docs: Nginx config update (#8397)
* Update reverse-proxy.md

* Update reverse-proxy.md

* Update reverse-proxy.md

* Update reverse-proxy.md

* Update reverse-proxy.md

* Update reverse-proxy.md

* Update reverse-proxy.md

* Update reverse-proxy.md
2024-03-30 21:48:37 -05:00
mmomjian b6af7788e1 feat(server): extensions for MPEG and 3GP (#8400)
* Update mime-types.spec.ts

* Update mime-types.ts
2024-03-30 21:48:01 -05:00
mmomjian c4bb9f49ff Fix repair page typo (#8401)
Update +page.svelte
2024-03-30 21:47:00 -05:00
mmomjian 8e5695f06d Add docs for Postgres standalone setup (#8343)
* Create postgres-standalone.md

* Update postgres-standalone.md

* Update postgres-standalone.md

* Update postgres-standalone.md

* Update postgres-standalone.md

* Update postgres-standalone.md

* Update postgres-standalone.md

* Update docs/docs/administration/postgres-standalone.md

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* Update docs/docs/administration/postgres-standalone.md

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* Update docs/docs/administration/postgres-standalone.md

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* Update docs/docs/administration/postgres-standalone.md

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* Update docs/docs/administration/postgres-standalone.md

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* Update postgres-standalone.md

* Update postgres-standalone.md

Planning to write a guide in the future about setting up streaming database backups

* Update postgres-standalone.md

* Update postgres-standalone.md

* Update postgres-standalone.md

* Update postgres-standalone.md

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2024-03-30 21:35:06 -05:00
Mert 395c28f5fa fix(server): parameter for all places query (#8346)
* fix parameter

* add e2e
2024-03-31 02:29:02 +00:00
xethlyx 3e5183606c docs: fix typo (#8396) 2024-03-30 21:57:19 -04:00
martin 6a36bbd1d1 fix(web): multiple fixes for the webUI (#8368)
fix: multiple fixes for the webUI
2024-03-30 10:14:41 -05:00
Jason Rasmussen 4b39d37cae fix: sql generation issues (#8361)
chore: fix sql gen issues
2024-03-30 00:16:06 -04:00
Jason Rasmussen 25c9b779e4 fix: map theme auth in shared links (#8359)
fix: map theme auth
2024-03-29 09:43:30 -05:00
Ben Basten fcc3b81745 feat(web, a11y): add labels! (#8354)
* feat(web, a11y): add labels!

* fix: move required prop to the top of the list
2024-03-29 08:48:07 -04:00
Daniel Dietzler 6f677b4fae refactor(server): extract add/remove assets logic to utility function (#8329)
extract add/remove assets logic to utility function

fix tests

chore: generate sql

foo
2024-03-29 07:56:16 -04:00
mmomjian 78f202603c docs: typo on backup script (#8349)
Update backup-and-restore.md
2024-03-28 23:49:55 -04:00
Daniel Dietzler b8c5363a15 refactor(server): move timeline operations to their own controller/service (#8325)
* move timeline operations to their own controller/service

* chore: open api

* move e2e tests
2024-03-28 23:20:40 -04:00
Daniel Dietzler b8b3c487d4 fix(server): map style not being available for shared assets (#8341)
* fix map style not being available for shared assets

* add e2e test
2024-03-28 23:19:05 -04:00
Jonathan Jogenfors ec48fccb30 fix(server): add missing file extensions to library files (#8342)
* fix file extensions

* fix tests

* fix formatting

* fixed bug

* fix merts comments
2024-03-28 22:51:07 -04:00
Alex 3f61019ca1 chore: post release tasks 2024-03-28 13:49:18 -05:00
Alex The Bot 16513b4a6e Version v1.100.0 2024-03-28 18:36:38 +00:00
Alex 9b705e4450 chore(server): open-api memory lane number generation (#8314)
chore(server): openaapi memory lane number generation
2024-03-27 22:27:26 -05:00
Ben Basten e1c2135850 feat(web, a11y): focus management for modals and popups (#8298)
* feat(web, a11y): focus management for modals and popups

* feat: hide asset options dropdown on escape key
2024-03-27 15:55:27 -05:00
Jason Rasmussen 9fe80c25eb fix: memory lane assets in ascending order (#8309)
* fix: memory lane asset order

* chore: deprecate title

* chore: open-api

* chore: rename years => yearsAgo
2024-03-27 16:14:29 -04:00
Sam Holton 13b11a39a9 feat(web) add filter when viewing all people in search box (#7997)
* feat(web) add filter when viewing all people in search box

* chore: svelte check

* pr feedback: fix vertical spacing to eliminate jump when filter appears

* pr feedback

* simplify filter logic

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-03-27 14:58:38 -05:00
Ethan Margaillan 8bf571bf48 feat(web): better UX when creating a new album (#8270)
* feat(web): ask user before going to newly created album

* feat(web): add button option to notification cards

* feat(web): allow html messages in notification cards

* show album -> view album

* remove 'link' action from notifications

* remove unused type
2024-03-27 14:47:42 -05:00
Jason Rasmussen 613b544bf0 feat(cli): better server info output (#8307)
* feat(cli): server-info command prints url and user email

* chore: clean up

---------

Co-authored-by: 澪 <mio@mio19.uk>
2024-03-27 20:01:36 +01:00
renovate[bot] 916603d2d4 fix(deps): update typescript-projects (#8287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-27 12:07:49 -04:00
Jason Rasmussen e30eecba2c chore(cli): prepare release (#8296)
chore: prepare release
2024-03-26 18:56:16 +00:00
Alex 3a94be0212 chore(web): style tab button group and search bar (#8292)
* chore(web): style tab button group and search bar

* responsive design
2024-03-26 10:36:15 -05:00
renovate[bot] 6295edcdb7 chore(deps): update base-image to v20240326 (major) (#8291)
chore(deps): update base-image to v20240326

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-26 09:17:48 -04:00
Jordy 335b4937ed fix(docs): fix path in readme files (#8289)
fix(docs): paths for images
2024-03-26 13:07:55 +00:00
indam 06da0469c4 Update README_zh_CN.md to sync with README.md (#8282) 2024-03-26 10:39:37 +00:00
renovate[bot] 1ad893ded4 fix(deps): update typescript-projects (#8281)
* fix(deps): update typescript-projects

* chore: regenerate sql

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-26 02:39:57 +00:00
Jordy 636f5fb933 feat(docs): removed deprecated version line in docker compose files (#8276)
Removed deprecated version line in docker compose files
2024-03-25 19:20:49 -04:00
Mert c45e28ab53 refactor(server): metric repo (#8278)
* refactor

* redundant `implements`

* simplify

* remove `enabled`
2024-03-25 23:15:11 +00:00
Daniel Dietzler c56c04a82b refactor(server): rename filesystem provider -> storage repo (#8277)
rename filesystem provider -> storage repo
2024-03-25 20:45:21 +00:00
Daniel Dietzler d431d37454 feat(server): dark mode support for star history (#8259)
use theme sensitive star history graphic
2024-03-25 08:20:26 -04:00
Daniel Dietzler 1694dd146e feat(server): add immich cli to container (#8239)
add immich cli to server container
2024-03-25 08:18:33 -04:00
Mert 4a6a0aa142 fix(server): places page not working with partner sharing (#8257) 2024-03-25 01:59:11 -04:00
Keshav Prateek Pandey c788160532 chore(docs): Update docker compose readme with wget download option (#8250)
Add download option to wget
2024-03-25 05:01:29 +00:00
James Wilson cc66159f04 fix(web): Correct minor typo in new version check copy (#8253)
Fix typo in new-version-check-settings.svelte
2024-03-24 23:35:06 -04:00
Mert c58a70ac8f feat(server): job metrics (#8255)
* metric repo

* add metric repo

* remove unused import

* formatting

* fix

* try disabling job metrics for e2e

* import otel in test module
2024-03-24 23:02:04 -04:00
Alex 1855aaea99 chore(web): favicon without white bg (#8251) 2024-03-24 16:44:59 -05:00
Alex 3901b5da44 fix(web): default album view (#8247)
* fix(web): default album view

* linting
2024-03-24 20:44:22 +00:00
martin 5dc59b591d refactor(web): albums list (2) (#8214)
* refactor: albums list

* fix: responsive design

* keep albums in sharing
2024-03-24 13:07:20 -05:00
Kokul Shanmugharajah 96a5710932 (docs) Update XMP sidecar docs to include the fact that Immich will look for photo.ext.xmp and photo.xmp (#8235)
Update XMP sidecar docs
2024-03-24 08:45:42 -04:00
aviv926 d36d32d07b feat(docs): Changes to the Administration section. (#8078)
* Changes to the administration section

* Add ->

* chore: better explanation about jobs

* chore: improve system config wording

* chore: remove duplicate entry

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-24 02:04:32 +00:00
Jamie 727b3b9f53 chore(docs): update storage-template.md (#8154)
Update _storage-template.md
2024-03-23 21:21:46 -04:00
Timothy Pillow c85563da50 Update command-line-interface.md (#8213)
* Update command-line-interface.md

Update documentation for CLI commands.

* chore: update docs

* chore: login-key => login

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-23 20:24:53 +00:00
Jason Rasmussen a771c563ba chore(server): remove pre-installed cli (#8224) 2024-03-23 16:07:39 -04:00
aviv926 3cc800f93a feat(docs): New repair and statistics pages (#8030)
* New repair and statistics page

* PR Feedback

* New

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-23 19:43:10 +00:00
mmomjian b449feb3e1 docs: Fix documentation for running as a non-root Docker user (#8218)
* Update FAQ.mdx

* Update FAQ.mdx

* Update FAQ.mdx
2024-03-23 15:21:40 -04:00
Mert b07a565e34 chore(server): change upsert signature for search repo (#8210)
* upsert embedding

* remove unused imports
2024-03-23 14:37:06 -04:00
Jason Rasmussen 787eebcf1e refactor(server): new password repo method (#8208) 2024-03-23 14:33:25 -04:00
Mert 604b8ff17c chore(server): remove getByDate from asset repo (#8211)
* remove getByDate

* remove unused import
2024-03-22 23:20:16 -05:00
Jason Rasmussen 6e93ddf2f1 refactor: server events (#8204)
* refactor: server events

* fix typo

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2024-03-22 18:24:02 -04:00
Jason Rasmussen b6e4be72f0 chore(server): consolidate dto files (#8201)
chore: conoslidate dto files
2024-03-22 16:36:20 -04:00
Jason Rasmussen 75aa8e6621 chore(cli): rename commands (#8200)
* chore(cli): rename login command

* chore: rename key/url
2024-03-22 15:09:04 -04:00
Jason Rasmussen 5b7417bf64 refactor: cli (#8199)
* refactor(cli): upload asset

* chore: e2e tests
2024-03-22 14:38:00 -04:00
Jason Rasmussen db744f500b refactor(cli): crawl service (#8190) 2024-03-22 10:30:24 -04:00
Alex a56cf35d8c fix(mobile): recently add view show other user assets (#8184) 2024-03-22 08:41:55 -05:00
Kokul Shanmugharajah d1e6843f3e feat(cli) CLI when uploading photo.EXT, it detects both photo.EXT.xmp and photo.xmp (#8186)
* Initial implementation

* chore: remove duplicate access check

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-22 13:00:27 +00:00
natedawg d18868873e chore: update readme screenshot (#8182)
Co-authored-by: natedawg <nate@natedawg.net>
2024-03-22 04:31:36 +00:00
Eliezer Graber 827014fa4b fix(mobile): android adaptive icon new logo (#8180)
* Use new logo for Android monochrome adaptive icon

* Use new logo for Android adaptive icon

* Add Android monochrome adaptive icon

* Remove ic_launch_foreground.png from drawable res

  - The mipmap res directories have an ic_launcher.png file that will be used on versions lower than 26
   - The adaptive icon will be used over versions 26 and above
2024-03-21 21:59:49 -05:00
Alex 944b33983c fix(mobile): scroll stickiness (#8166) 2024-03-21 21:58:40 -05:00
renovate[bot] 2641185af2 chore(deps): update grafana/grafana docker tag to v10.4.1 (#8168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-21 20:59:13 -05:00
278 changed files with 6294 additions and 5027 deletions
+6 -2
View File
@@ -131,6 +131,10 @@ If you feel like this is the right cause and the app is something you are seeing
## 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 href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
+210 -218
View File
@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.1.0",
"version": "2.2.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"lodash-es": "^4.17.21"
@@ -47,7 +47,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.99.0",
"version": "1.100.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -300,9 +300,9 @@
"dev": true
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
@@ -316,9 +316,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
@@ -332,9 +332,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
@@ -348,9 +348,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
@@ -364,9 +364,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
@@ -380,9 +380,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
@@ -396,9 +396,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
@@ -412,9 +412,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
@@ -428,9 +428,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
@@ -444,9 +444,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
@@ -460,9 +460,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
@@ -476,9 +476,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
@@ -492,9 +492,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
@@ -508,9 +508,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
@@ -524,9 +524,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
@@ -540,9 +540,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
@@ -556,9 +556,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
@@ -572,9 +572,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
@@ -588,9 +588,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
@@ -604,9 +604,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
@@ -620,9 +620,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
@@ -636,9 +636,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
@@ -652,9 +652,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
@@ -1230,9 +1230,9 @@
}
},
"node_modules/@types/node": {
"version": "20.11.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz",
"integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==",
"version": "20.11.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -1251,16 +1251,16 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.2.0.tgz",
"integrity": "sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz",
"integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/type-utils": "7.2.0",
"@typescript-eslint/utils": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/type-utils": "7.3.1",
"@typescript-eslint/utils": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -1269,7 +1269,7 @@
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1286,19 +1286,19 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
"integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz",
"integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/typescript-estree": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"debug": "^4.3.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1314,16 +1314,16 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz",
"integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz",
"integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0"
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1331,18 +1331,18 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz",
"integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz",
"integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/utils": "7.2.0",
"@typescript-eslint/typescript-estree": "7.3.1",
"@typescript-eslint/utils": "7.3.1",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1358,12 +1358,12 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz",
"integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz",
"integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1371,13 +1371,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz",
"integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz",
"integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1386,7 +1386,7 @@
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1399,21 +1399,21 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz",
"integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz",
"integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/typescript-estree": "7.3.1",
"semver": "^7.5.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1424,16 +1424,16 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz",
"integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz",
"integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/types": "7.3.1",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1447,9 +1447,9 @@
"dev": true
},
"node_modules/@vitest/coverage-v8": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz",
"integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz",
"integrity": "sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@@ -1457,12 +1457,13 @@
"debug": "^4.3.4",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^4.0.1",
"istanbul-lib-source-maps": "^5.0.4",
"istanbul-reports": "^3.1.6",
"magic-string": "^0.30.5",
"magicast": "^0.3.3",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"test-exclude": "^6.0.0",
"v8-to-istanbul": "^9.2.0"
},
@@ -1470,17 +1471,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "1.3.1"
"vitest": "1.4.0"
}
},
"node_modules/@vitest/expect": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
"integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz",
"integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==",
"dev": true,
"dependencies": {
"@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.1",
"@vitest/spy": "1.4.0",
"@vitest/utils": "1.4.0",
"chai": "^4.3.10"
},
"funding": {
@@ -1488,12 +1489,12 @@
}
},
"node_modules/@vitest/runner": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
"integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz",
"integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==",
"dev": true,
"dependencies": {
"@vitest/utils": "1.3.1",
"@vitest/utils": "1.4.0",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
@@ -1529,9 +1530,9 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
"integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz",
"integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==",
"dev": true,
"dependencies": {
"magic-string": "^0.30.5",
@@ -1543,9 +1544,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
"integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz",
"integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==",
"dev": true,
"dependencies": {
"tinyspy": "^2.2.0"
@@ -1555,9 +1556,9 @@
}
},
"node_modules/@vitest/utils": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
"integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz",
"integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==",
"dev": true,
"dependencies": {
"diff-sequences": "^29.6.3",
@@ -2037,9 +2038,9 @@
}
},
"node_modules/esbuild": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"bin": {
@@ -2049,29 +2050,29 @@
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.19.12",
"@esbuild/android-arm": "0.19.12",
"@esbuild/android-arm64": "0.19.12",
"@esbuild/android-x64": "0.19.12",
"@esbuild/darwin-arm64": "0.19.12",
"@esbuild/darwin-x64": "0.19.12",
"@esbuild/freebsd-arm64": "0.19.12",
"@esbuild/freebsd-x64": "0.19.12",
"@esbuild/linux-arm": "0.19.12",
"@esbuild/linux-arm64": "0.19.12",
"@esbuild/linux-ia32": "0.19.12",
"@esbuild/linux-loong64": "0.19.12",
"@esbuild/linux-mips64el": "0.19.12",
"@esbuild/linux-ppc64": "0.19.12",
"@esbuild/linux-riscv64": "0.19.12",
"@esbuild/linux-s390x": "0.19.12",
"@esbuild/linux-x64": "0.19.12",
"@esbuild/netbsd-x64": "0.19.12",
"@esbuild/openbsd-x64": "0.19.12",
"@esbuild/sunos-x64": "0.19.12",
"@esbuild/win32-arm64": "0.19.12",
"@esbuild/win32-ia32": "0.19.12",
"@esbuild/win32-x64": "0.19.12"
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/escalade": {
@@ -2858,14 +2859,14 @@
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
"integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz",
"integrity": "sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0",
"source-map": "^0.6.1"
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
@@ -3488,9 +3489,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"dev": true,
"funding": [
{
@@ -3509,7 +3510,7 @@
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
"source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -3987,19 +3988,10 @@
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -4376,9 +4368,9 @@
}
},
"node_modules/typescript": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
@@ -4464,14 +4456,14 @@
}
},
"node_modules/vite": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.6.tgz",
"integrity": "sha512-yYIAZs9nVfRJ/AiOLCA91zzhjsHUgMjB+EigzFb6W2XTLO8JixBCKCjvhKZaye+NKYHCrkv3Oh50dH9EdLU2RA==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.3.tgz",
"integrity": "sha512-+i1oagbvkVIhEy9TnEV+fgXsng13nZM90JQbrcPrf6DvW2mXARlz+DK7DLiDP+qeKoD1FCVx/1SpFL1CLq9Mhw==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
"postcss": "^8.4.35",
"rollup": "^4.2.0"
"esbuild": "^0.20.1",
"postcss": "^8.4.36",
"rollup": "^4.13.0"
},
"bin": {
"vite": "bin/vite.js"
@@ -4519,9 +4511,9 @@
}
},
"node_modules/vite-node": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
"integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz",
"integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
@@ -4560,16 +4552,16 @@
}
},
"node_modules/vitest": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
"integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz",
"integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==",
"dev": true,
"dependencies": {
"@vitest/expect": "1.3.1",
"@vitest/runner": "1.3.1",
"@vitest/snapshot": "1.3.1",
"@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.1",
"@vitest/expect": "1.4.0",
"@vitest/runner": "1.4.0",
"@vitest/snapshot": "1.4.0",
"@vitest/spy": "1.4.0",
"@vitest/utils": "1.4.0",
"acorn-walk": "^8.3.2",
"chai": "^4.3.10",
"debug": "^4.3.4",
@@ -4583,7 +4575,7 @@
"tinybench": "^2.5.1",
"tinypool": "^0.8.2",
"vite": "^5.0.0",
"vite-node": "1.3.1",
"vite-node": "1.4.0",
"why-is-node-running": "^2.2.2"
},
"bin": {
@@ -4598,8 +4590,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.3.1",
"@vitest/ui": "1.3.1",
"@vitest/browser": "1.4.0",
"@vitest/ui": "1.4.0",
"happy-dom": "*",
"jsdom": "*"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.1.0",
"version": "2.2.0",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
+280 -403
View File
@@ -1,5 +1,7 @@
import {
Action,
AssetBulkUploadCheckResult,
AssetFileUploadResponseDto,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
@@ -8,445 +10,320 @@ import {
getSupportedMediaTypes,
} from '@immich/sdk';
import byteSize from 'byte-size';
import cliProgress from 'cli-progress';
import { chunk, zip } from 'lodash-es';
import { createHash } from 'node:crypto';
import fs, { createReadStream } from 'node:fs';
import { access, constants, stat, unlink } from 'node:fs/promises';
import { Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es';
import { Stats, createReadStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import os from 'node:os';
import { basename } from 'node:path';
import { CrawlService } from 'src/services/crawl.service';
import { BaseOptions, authenticate } from 'src/utils';
import path, { basename } from 'node:path';
import { BaseOptions, authenticate, crawl, sha1 } from 'src/utils';
const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][];
const s = (count: number) => (count === 1 ? '' : 's');
enum CheckResponseStatus {
ACCEPT = 'accept',
REJECT = 'reject',
DUPLICATE = 'duplicate',
}
// TODO figure out why `id` is missing
type AssetBulkUploadCheckResults = Array<AssetBulkUploadCheckResult & { id: string }>;
type Asset = { id: string; filepath: string };
class Asset {
readonly path: string;
id?: string;
deviceAssetId?: string;
fileCreatedAt?: Date;
fileModifiedAt?: Date;
sidecarPath?: string;
fileSize?: number;
interface UploadOptionsDto {
recursive?: boolean;
exclusionPatterns?: string[];
dryRun?: boolean;
skipHash?: boolean;
delete?: boolean;
album?: boolean;
albumName?: string;
includeHidden?: boolean;
concurrency: number;
}
constructor(path: string) {
this.path = path;
class UploadFile extends File {
constructor(
private filepath: string,
private _size: number,
) {
super([], basename(filepath));
}
async prepare() {
const stats = await stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replaceAll(/\s+/g, '');
this.fileCreatedAt = stats.mtime;
this.fileModifiedAt = stats.mtime;
this.fileSize = stats.size;
this.albumName = this.extractAlbumName();
get size() {
return this._size;
}
async getUploadFormData(): Promise<FormData> {
if (!this.deviceAssetId) {
throw new Error('Device asset id not set');
}
if (!this.fileCreatedAt) {
throw new Error('File created at not set');
}
if (!this.fileModifiedAt) {
throw new Error('File modified at not set');
}
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
let sidecarData: Blob | undefined = undefined;
try {
await access(sideCarPath, constants.R_OK);
sidecarData = new File([await fs.openAsBlob(sideCarPath)], basename(sideCarPath));
} catch {}
const data: any = {
assetData: new File([await fs.openAsBlob(this.path)], basename(this.path)),
deviceAssetId: this.deviceAssetId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt.toISOString(),
fileModifiedAt: this.fileModifiedAt.toISOString(),
isFavorite: String(false),
};
const formData = new FormData();
for (const property in data) {
formData.append(property, data[property]);
}
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
return formData;
}
async delete(): Promise<void> {
return unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
return await sha1(this.path);
}
private extractAlbumName(): string | undefined {
return os.platform() === 'win32' ? this.path.split('\\').at(-2) : this.path.split('/').at(-2);
stream() {
return createReadStream(this.filepath) as any;
}
}
class UploadOptionsDto {
recursive? = false;
exclusionPatterns?: string[] = [];
dryRun? = false;
skipHash? = false;
delete? = false;
album? = false;
albumName? = '';
includeHidden? = false;
concurrency? = 4;
}
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
await authenticate(baseOptions);
export const upload = (paths: string[], baseOptions: BaseOptions, uploadOptions: UploadOptionsDto) =>
new UploadCommand().run(paths, baseOptions, uploadOptions);
const files = await scan(paths, options);
if (files.length === 0) {
console.log('No files found, exiting');
return;
}
// TODO refactor this
class UploadCommand {
public async run(paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto): Promise<void> {
await authenticate(baseOptions);
const { newFiles, duplicates } = await checkForDuplicates(files, options);
console.log('Crawling for assets...');
const files = await this.getFiles(paths, options);
const newAssets = await uploadFiles(newFiles, options);
await updateAlbums([...newAssets, ...duplicates], options);
await deleteFiles(newFiles, options);
};
if (files.length === 0) {
console.log('No assets found, exiting');
return;
const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
const { image, video } = await getSupportedMediaTypes();
console.log('Crawling for assets...');
const files = await crawl({
pathsToCrawl,
recursive: options.recursive,
exclusionPatterns: options.exclusionPatterns,
includeHidden: options.includeHidden,
extensions: [...image, ...video],
});
return files;
};
const checkForDuplicates = async (files: string[], { concurrency }: UploadOptionsDto) => {
const progressBar = new SingleBar(
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
progressBar.start(files.length, 0);
const newFiles: string[] = [];
const duplicates: Asset[] = [];
try {
// TODO refactor into a queue
for (const items of chunk(files, concurrency)) {
const dto = await Promise.all(items.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })));
const { results } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
for (const { id: filepath, assetId, action } of results as AssetBulkUploadCheckResults) {
if (action === Action.Accept) {
newFiles.push(filepath);
} else {
// rejects are always duplicates
duplicates.push({ id: assetId as string, filepath });
}
progressBar.increment();
}
}
} finally {
progressBar.stop();
}
const assetsToCheck = files.map((path) => new Asset(path));
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
const { newAssets, duplicateAssets } = await this.checkAssets(assetsToCheck, options.concurrency ?? 4);
return { newFiles, duplicates };
};
const totalSizeUploaded = await this.upload(newAssets, options);
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
if (newAssets.length === 0) {
console.log('All assets were already uploaded, nothing to do.');
} else {
console.log(
`${messageStart} uploaded ${newAssets.length} asset${newAssets.length === 1 ? '' : 's'} (${byteSize(totalSizeUploaded)})`,
const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptionsDto): Promise<Asset[]> => {
if (files.length === 0) {
console.log('All assets were already uploaded, nothing to do.');
return [];
}
// Compute total size first
let totalSize = 0;
const statsMap = new Map<string, Stats>();
for (const filepath of files) {
const stats = await stat(filepath);
statsMap.set(filepath, stats);
totalSize += stats.size;
}
if (dryRun) {
console.log(`Would have uploaded ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`);
return [];
}
const uploadProgress = new SingleBar(
{ format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}' },
Presets.shades_classic,
);
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
let totalSizeUploaded = 0;
const newAssets: Asset[] = [];
try {
for (const items of chunk(files, concurrency)) {
await Promise.all(
items.map(async (filepath) => {
const stats = statsMap.get(filepath) as Stats;
const response = await uploadFile(filepath, stats);
totalSizeUploaded += stats.size ?? 0;
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
newAssets.push({ id: response.id, filepath });
return response;
}),
);
}
} finally {
uploadProgress.stop();
}
if (options.album || options.albumName) {
const { createdAlbumCount, updatedAssetCount } = await this.updateAlbums(
[...newAssets, ...duplicateAssets],
options,
console.log(`Successfully uploaded ${newAssets.length} asset${s(newAssets.length)} (${byteSize(totalSizeUploaded)})`);
return newAssets;
};
const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadResponseDto> => {
const { baseUrl, headers } = defaults;
const assetPath = path.parse(input);
const noExtension = path.join(assetPath.dir, assetPath.name);
const sidecarsFiles = await Promise.all(
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
try {
const stats = await stat(sidecarPath);
return new UploadFile(sidecarPath, stats.size);
} catch {
return false;
}
}),
);
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
const formData = new FormData();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI');
formData.append('fileCreatedAt', stats.mtime.toISOString());
formData.append('fileModifiedAt', stats.mtime.toISOString());
formData.append('fileSize', String(stats.size));
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
const response = await fetch(`${baseUrl}/asset/upload`, {
method: 'post',
redirect: 'error',
headers: headers as Record<string, string>,
body: formData,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(await response.text());
}
return response.json();
};
const deleteFiles = async (files: string[], options: UploadOptionsDto): Promise<void> => {
if (!options.delete) {
return;
}
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
return;
}
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new SingleBar(
{ format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
deletionProgress.start(files.length, 0);
try {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: string) => unlink(input)));
deletionProgress.update(assetBatch.length);
}
} finally {
deletionProgress.stop();
}
};
const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
if (!options.album && !options.albumName) {
return;
}
const { dryRun, concurrency } = options;
const albums = await getAllAlbums({});
const existingAlbums = new Map(albums.map((album) => [album.albumName, album.id]));
const newAlbums: Set<string> = new Set();
for (const { filepath } of assets) {
const albumName = getAlbumName(filepath, options);
if (albumName && !existingAlbums.has(albumName)) {
newAlbums.add(albumName);
}
}
if (dryRun) {
// TODO print asset counts for new albums
console.log(`Would have created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Would have updated ${assets.length} asset${s(assets.length)}`);
return;
}
const progressBar = new SingleBar(
{ format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums' },
Presets.shades_classic,
);
progressBar.start(newAlbums.size, 0);
try {
for (const albumNames of chunk([...newAlbums], concurrency)) {
const items = await Promise.all(
albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } })),
);
console.log(`${messageStart} created ${createdAlbumCount} new album${createdAlbumCount === 1 ? '' : 's'}`);
console.log(`${messageStart} updated ${updatedAssetCount} asset${updatedAssetCount === 1 ? '' : 's'}`);
}
if (!options.delete) {
return;
}
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
return;
}
console.log('Deleting assets that have been uploaded...');
await this.deleteAssets(newAssets, options);
}
public async checkAssets(
assetsToCheck: Asset[],
concurrency: number,
): Promise<{ newAssets: Asset[]; duplicateAssets: Asset[]; rejectedAssets: Asset[] }> {
for (const assets of chunk(assetsToCheck, concurrency)) {
await Promise.all(assets.map((asset: Asset) => asset.prepare()));
}
const checkProgress = new cliProgress.SingleBar(
{ format: 'Checking assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
cliProgress.Presets.shades_classic,
);
checkProgress.start(assetsToCheck.length, 0);
const newAssets = [];
const duplicateAssets = [];
const rejectedAssets = [];
try {
for (const assets of chunk(assetsToCheck, concurrency)) {
const checkedAssets = await this.getStatus(assets);
for (const checked of checkedAssets) {
if (checked.status === CheckResponseStatus.ACCEPT) {
newAssets.push(checked.asset);
} else if (checked.status === CheckResponseStatus.DUPLICATE) {
duplicateAssets.push(checked.asset);
} else {
rejectedAssets.push(checked.asset);
}
checkProgress.increment();
}
for (const { id, albumName } of items) {
existingAlbums.set(albumName, id);
}
} finally {
checkProgress.stop();
progressBar.increment(albumNames.length);
}
return { newAssets, duplicateAssets, rejectedAssets };
} finally {
progressBar.stop();
}
public async upload(assetsToUpload: Asset[], options: UploadOptionsDto): Promise<number> {
let totalSize = 0;
console.log(`Successfully created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Successfully updated ${assets.length} asset${s(assets.length)}`);
// Compute total size first
for (const asset of assetsToUpload) {
totalSize += asset.fileSize ?? 0;
const albumToAssets = new Map<string, string[]>();
for (const asset of assets) {
const albumName = getAlbumName(asset.filepath, options);
if (!albumName) {
continue;
}
if (options.dryRun) {
return totalSize;
}
const uploadProgress = new cliProgress.SingleBar(
{
format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}',
},
cliProgress.Presets.shades_classic,
);
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
let totalSizeUploaded = 0;
try {
for (const assets of chunk(assetsToUpload, options.concurrency)) {
const ids = await this.uploadAssets(assets);
for (const [asset, id] of zipDefined(assets, ids)) {
asset.id = id;
if (asset.fileSize) {
totalSizeUploaded += asset.fileSize ?? 0;
} else {
console.log(`Could not determine file size for ${asset.path}`);
}
}
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
const albumId = existingAlbums.get(albumName);
if (albumId) {
if (!albumToAssets.has(albumId)) {
albumToAssets.set(albumId, []);
}
} finally {
uploadProgress.stop();
albumToAssets.get(albumId)?.push(asset.id);
}
return totalSizeUploaded;
}
public async getFiles(paths: string[], options: UploadOptionsDto): Promise<string[]> {
const inputFiles: string[] = [];
for (const pathArgument of paths) {
const fileStat = await fs.promises.lstat(pathArgument);
if (fileStat.isFile()) {
inputFiles.push(pathArgument);
const albumUpdateProgress = new SingleBar(
{ format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
albumUpdateProgress.start(assets.length, 0);
try {
for (const [albumId, assets] of albumToAssets.entries()) {
for (const assetBatch of chunk(assets, Math.min(1000 * concurrency, 65_000))) {
await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
albumUpdateProgress.increment(assetBatch.length);
}
}
const files: string[] = await this.crawl(paths, options);
files.push(...inputFiles);
return files;
} finally {
albumUpdateProgress.stop();
}
};
public async getAlbums(): Promise<Map<string, string>> {
const existingAlbums = await getAllAlbums({});
const albumMapping = new Map<string, string>();
for (const album of existingAlbums) {
albumMapping.set(album.albumName, album.id);
}
return albumMapping;
}
public async updateAlbums(
assets: Asset[],
options: UploadOptionsDto,
): Promise<{ createdAlbumCount: number; updatedAssetCount: number }> {
if (options.albumName) {
for (const asset of assets) {
asset.albumName = options.albumName;
}
}
const existingAlbums = await this.getAlbums();
const assetsToUpdate = assets.filter(
(asset): asset is Asset & { albumName: string; id: string } => !!(asset.albumName && asset.id),
);
const newAlbumsSet: Set<string> = new Set();
for (const asset of assetsToUpdate) {
if (!existingAlbums.has(asset.albumName)) {
newAlbumsSet.add(asset.albumName);
}
}
const newAlbums = [...newAlbumsSet];
if (options.dryRun) {
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
}
const albumCreationProgress = new cliProgress.SingleBar(
{
format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums',
},
cliProgress.Presets.shades_classic,
);
albumCreationProgress.start(newAlbums.length, 0);
try {
for (const albumNames of chunk(newAlbums, options.concurrency)) {
const newAlbumIds = await Promise.all(
albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } }).then((r) => r.id)),
);
for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
existingAlbums.set(albumName, albumId);
}
albumCreationProgress.increment(albumNames.length);
}
} finally {
albumCreationProgress.stop();
}
const albumToAssets = new Map<string, string[]>();
for (const asset of assetsToUpdate) {
const albumId = existingAlbums.get(asset.albumName);
if (albumId) {
if (!albumToAssets.has(albumId)) {
albumToAssets.set(albumId, []);
}
albumToAssets.get(albumId)?.push(asset.id);
}
}
const albumUpdateProgress = new cliProgress.SingleBar(
{
format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
},
cliProgress.Presets.shades_classic,
);
albumUpdateProgress.start(assetsToUpdate.length, 0);
try {
for (const [albumId, assets] of albumToAssets.entries()) {
for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) {
await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
albumUpdateProgress.increment(assetBatch.length);
}
}
} finally {
albumUpdateProgress.stop();
}
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
}
public async deleteAssets(assets: Asset[], options: UploadOptionsDto): Promise<void> {
const deletionProgress = new cliProgress.SingleBar(
{
format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
},
cliProgress.Presets.shades_classic,
);
deletionProgress.start(assets.length, 0);
try {
for (const assetBatch of chunk(assets, options.concurrency)) {
await Promise.all(assetBatch.map((asset: Asset) => asset.delete()));
deletionProgress.update(assetBatch.length);
}
} finally {
deletionProgress.stop();
}
}
private async getStatus(assets: Asset[]): Promise<{ asset: Asset; status: CheckResponseStatus }[]> {
const checkResponse = await this.checkHashes(assets);
const responses = [];
for (const [check, asset] of zipDefined(checkResponse, assets)) {
if (check.assetId) {
asset.id = check.assetId;
}
if (check.action === 'accept') {
responses.push({ asset, status: CheckResponseStatus.ACCEPT });
} else if (check.reason === 'duplicate') {
responses.push({ asset, status: CheckResponseStatus.DUPLICATE });
} else {
responses.push({ asset, status: CheckResponseStatus.REJECT });
}
}
return responses;
}
private async checkHashes(assetsToCheck: Asset[]): Promise<AssetBulkUploadCheckResult[]> {
const checksums = await Promise.all(assetsToCheck.map((asset) => asset.hash()));
const assetBulkUploadCheckDto = {
assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })),
};
const checkResponse = await checkBulkUpload({ assetBulkUploadCheckDto });
return checkResponse.results;
}
private async uploadAssets(assets: Asset[]): Promise<string[]> {
const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData()));
const results = await Promise.all(fileRequests.map((request) => this.uploadAsset(request)));
return results.map((response) => response.id);
}
private async crawl(paths: string[], options: UploadOptionsDto): Promise<string[]> {
const formatResponse = await getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
return crawlService.crawl({
pathsToCrawl: paths,
recursive: options.recursive,
exclusionPatterns: options.exclusionPatterns,
includeHidden: options.includeHidden,
});
}
private async uploadAsset(data: FormData): Promise<{ id: string }> {
const { baseUrl, headers } = defaults;
const response = await fetch(`${baseUrl}/asset/upload`, {
method: 'post',
redirect: 'error',
headers: headers as Record<string, string>,
body: data,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(await response.text());
}
return response.json();
}
}
const getAlbumName = (filepath: string, options: UploadOptionsDto) => {
const folderName = os.platform() === 'win32' ? filepath.split('\\').at(-2) : filepath.split('/').at(-2);
return options.albumName ?? folderName;
};
+4 -4
View File
@@ -3,12 +3,12 @@ import { existsSync } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils';
export const login = async (instanceUrl: string, apiKey: string, options: BaseOptions) => {
console.log(`Logging in to ${instanceUrl}`);
export const login = async (url: string, key: string, options: BaseOptions) => {
console.log(`Logging in to ${url}`);
const { configDirectory: configDir } = options;
await connect(instanceUrl, apiKey);
await connect(url, key);
const [error, userInfo] = await withError(getMyUserInfo());
if (error) {
@@ -27,7 +27,7 @@ export const login = async (instanceUrl: string, apiKey: string, options: BaseOp
}
}
await writeAuthFile(configDir, { instanceUrl, apiKey });
await writeAuthFile(configDir, { url, key });
console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`);
};
+18 -9
View File
@@ -1,15 +1,24 @@
import { getAssetStatistics, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { BaseOptions, authenticate } from 'src/utils';
export const serverInfo = async (options: BaseOptions) => {
await authenticate(options);
const { url } = await authenticate(options);
const versionInfo = await getServerVersion();
const mediaTypes = await getSupportedMediaTypes();
const stats = await getAssetStatistics({});
const [versionInfo, mediaTypes, stats, userInfo] = await Promise.all([
getServerVersion(),
getSupportedMediaTypes(),
getAssetStatistics({}),
getMyUserInfo(),
]);
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(`Statistics:\n Images: ${stats.images}\n Videos: ${stats.videos}\n Total: ${stats.total}`);
console.log(`Server Info (via ${userInfo.email})`);
console.log(` Url: ${url}`);
console.log(` Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(` Formats:`);
console.log(` Images: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(` Videos: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(` Statistics:`);
console.log(` Images: ${stats.images}`);
console.log(` Videos: ${stats.videos}`);
console.log(` Total: ${stats.total}`);
};
+1 -1
View File
@@ -19,7 +19,7 @@ const program = new Command()
.default(defaultConfigDirectory),
)
.addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL'))
.addOption(new Option('-k, --key [apiKey]', 'Immich API key').env('IMMICH_API_KEY'));
.addOption(new Option('-k, --key [key]', 'Immich API key').env('IMMICH_API_KEY'));
program
.command('login')
-70
View File
@@ -1,70 +0,0 @@
import { glob } from 'glob';
import * as fs from 'node:fs';
export class CrawlOptions {
pathsToCrawl!: string[];
recursive? = false;
includeHidden? = false;
exclusionPatterns?: string[];
}
export class CrawlService {
private readonly extensions!: string[];
constructor(image: string[], video: string[]) {
this.extensions = [...image, ...video].map((extension) => extension.replace('.', ''));
}
async crawl(options: CrawlOptions): Promise<string[]> {
const { recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
if (!pathsToCrawl) {
return [];
}
const patterns: string[] = [];
const crawledFiles: string[] = [];
for await (const currentPath of pathsToCrawl) {
try {
const stats = await fs.promises.stat(currentPath);
if (stats.isFile() || stats.isSymbolicLink()) {
crawledFiles.push(currentPath);
} else {
patterns.push(currentPath);
}
} catch (error: any) {
if (error.code === 'ENOENT') {
patterns.push(currentPath);
} else {
throw error;
}
}
}
let searchPattern: string;
if (patterns.length === 1) {
searchPattern = patterns[0];
} else if (patterns.length === 0) {
return crawledFiles;
} else {
searchPattern = '{' + patterns.join(',') + '}';
}
if (recursive) {
searchPattern = searchPattern + '/**/';
}
searchPattern = `${searchPattern}/*.{${this.extensions.join(',')}}`;
const globbedFiles = await glob(searchPattern, {
absolute: true,
nocase: true,
nodir: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
return [...crawledFiles, ...globbedFiles].sort();
}
}
@@ -1,14 +1,31 @@
import mockfs from 'mock-fs';
import { CrawlOptions, CrawlService } from './crawl.service';
import { CrawlOptions, crawl } from 'src/utils';
interface Test {
test: string;
options: CrawlOptions;
options: Omit<CrawlOptions, 'extensions'>;
files: Record<string, boolean>;
}
const cwd = process.cwd();
const extensions = [
'.jpg',
'.jpeg',
'.png',
'.heif',
'.heic',
'.tif',
'.nef',
'.webp',
'.tiff',
'.dng',
'.gif',
'.mov',
'.mp4',
'.webm',
];
const tests: Test[] = [
{
test: 'should return empty when crawling an empty path list',
@@ -251,12 +268,7 @@ const tests: Test[] = [
},
];
describe(CrawlService.name, () => {
const sut = new CrawlService(
['.jpg', '.jpeg', '.png', '.heif', '.heic', '.tif', '.nef', '.webp', '.tiff', '.dng', '.gif'],
['.mov', '.mp4', '.webm'],
);
describe('crawl', () => {
afterEach(() => {
mockfs.restore();
});
@@ -266,7 +278,7 @@ describe(CrawlService.name, () => {
it(test, async () => {
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
const actual = await sut.crawl(options);
const actual = await crawl({ ...options, extensions });
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => file);
+105 -22
View File
@@ -1,54 +1,61 @@
import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk';
import { readFile, writeFile } from 'node:fs/promises';
import { glob } from 'glob';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { readFile, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import yaml from 'yaml';
export interface BaseOptions {
configDirectory: string;
apiKey?: string;
instanceUrl?: string;
key?: string;
url?: string;
}
export interface AuthDto {
instanceUrl: string;
apiKey: string;
}
export type AuthDto = { url: string; key: string };
type OldAuthDto = { instanceUrl: string; apiKey: string };
export const authenticate = async (options: BaseOptions): Promise<void> => {
const { configDirectory: configDir, instanceUrl, apiKey } = options;
export const authenticate = async (options: BaseOptions): Promise<AuthDto> => {
const { configDirectory: configDir, url, key } = options;
// provided in command
if (instanceUrl && apiKey) {
await connect(instanceUrl, apiKey);
return;
if (url && key) {
return connect(url, key);
}
// fallback to file
// fallback to auth file
const config = await readAuthFile(configDir);
await connect(config.instanceUrl, config.apiKey);
const auth = await connect(config.url, config.key);
if (auth.url !== config.url) {
await writeAuthFile(configDir, auth);
}
return auth;
};
export const connect = async (instanceUrl: string, apiKey: string): Promise<void> => {
const wellKnownUrl = new URL('.well-known/immich', instanceUrl);
export const connect = async (url: string, key: string) => {
const wellKnownUrl = new URL('.well-known/immich', url);
try {
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString();
if (endpoint !== instanceUrl) {
const endpoint = new URL(wellKnown.api.endpoint, url).toString();
if (endpoint !== url) {
console.debug(`Discovered API at ${endpoint}`);
}
instanceUrl = endpoint;
url = endpoint;
} catch {
// noop
}
defaults.baseUrl = instanceUrl;
defaults.headers = { 'x-api-key': apiKey };
defaults.baseUrl = url;
defaults.headers = { 'x-api-key': key };
const [error] = await withError(getMyUserInfo());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
process.exit(1);
}
return { url, key };
};
export const logError = (error: unknown, message: string) => {
@@ -66,7 +73,12 @@ export const readAuthFile = async (dir: string) => {
try {
const data = await readFile(getAuthFilePath(dir));
// TODO add class-transform/validation
return yaml.parse(data.toString()) as AuthDto;
const auth = yaml.parse(data.toString()) as AuthDto | OldAuthDto;
const { instanceUrl, apiKey } = auth as OldAuthDto;
if (instanceUrl && apiKey) {
return { url: instanceUrl, key: apiKey };
}
return auth as AuthDto;
} catch (error: Error | any) {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
console.log('No auth file exists. Please login first.');
@@ -87,3 +99,74 @@ export const withError = async <T>(promise: Promise<T>): Promise<[Error, undefin
return [error, undefined];
}
};
export interface CrawlOptions {
pathsToCrawl: string[];
recursive?: boolean;
includeHidden?: boolean;
exclusionPatterns?: string[];
extensions: string[];
}
export const crawl = async (options: CrawlOptions): Promise<string[]> => {
const { extensions: extensionsWithPeriod, recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
const extensions = extensionsWithPeriod.map((extension) => extension.replace('.', ''));
if (pathsToCrawl.length === 0) {
return [];
}
const patterns: string[] = [];
const crawledFiles: string[] = [];
for await (const currentPath of pathsToCrawl) {
try {
const stats = await stat(currentPath);
if (stats.isFile() || stats.isSymbolicLink()) {
crawledFiles.push(currentPath);
} else {
patterns.push(currentPath);
}
} catch (error: any) {
if (error.code === 'ENOENT') {
patterns.push(currentPath);
} else {
throw error;
}
}
}
let searchPattern: string;
if (patterns.length === 1) {
searchPattern = patterns[0];
} else if (patterns.length === 0) {
return crawledFiles;
} else {
searchPattern = '{' + patterns.join(',') + '}';
}
if (recursive) {
searchPattern = searchPattern + '/**/';
}
searchPattern = `${searchPattern}/*.{${extensions.join(',')}}`;
const globbedFiles = await glob(searchPattern, {
absolute: true,
nocase: true,
nodir: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
return [...crawledFiles, ...globbedFiles].sort();
};
export const sha1 = (filepath: string) => {
const hash = createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = createReadStream(filepath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

-2
View File
@@ -2,8 +2,6 @@
# - https://immich.app/docs/developer/setup
# - https://immich.app/docs/developer/troubleshooting
version: '3.8'
name: immich-dev
x-server-build: &server-common
+1 -3
View File
@@ -1,5 +1,3 @@
version: '3.8'
name: immich-prod
x-server-build: &server-common
@@ -90,7 +88,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:10.4.0-ubuntu@sha256:c1f582b7cc4c1b9805d187b5600ce7879550a12ef6d29571da133c3d3fc67a9c
image: grafana/grafana:10.4.1-ubuntu@sha256:65e0e7d0f0b001cb0478bce5093bff917677dc308dd27a0aa4b3ac38e4fd877c
volumes:
- grafana-data:/var/lib/grafana
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
#
# WARNING: Make sure to use the docker-compose.yml of the current release:
#
+2 -2
View File
@@ -10,8 +10,8 @@ Hello everyone, it is my pleasure to deliver the new release of Immich to you. T
Some notable features are:
- [OAuth integration](#livephoto-ios-support-)
- [LivePhoto support on iOS](#oauth-integration-)
- OAuth integration
- LivePhoto support on iOS
- User config system
<!--truncate-->
+5 -1
View File
@@ -288,7 +288,11 @@ Immich components are typically deployed using docker. To see logs for deployed
### How can I run Immich as a non-root user?
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.
You may need to add an additional volume to `immich-microservices` that mounts internally to `/usr/src/app/.reverse-geocoding-dump`.
You may need to add mount points or docker volumes for the following internal container paths:
- `immich-machine-learning:/.config`
- `immich-machine-learning:/.cache`
- `redis:/data`
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
@@ -20,7 +20,7 @@ The recommended way to backup and restore the Immich database is to use the `pg_
<Tabs>
<TabItem value="Linux system based Backup" label="Linux system based Backup" default>
```bash title='Bash'
```bash title='Backup'
docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/backup/dump.sql.gz"
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+6 -2
View File
@@ -1,9 +1,13 @@
# Jobs
Several Immich functionalities are implemented as jobs, which run in the background. To view the status of a job navigate to the Administration Screen, and then the `Jobs` page.
The `immich-server` responds to API requests for data and files for the web and mobile app. To do this quickly and reliably, it offloads most other work to `immich-microservices` in the form of _jobs_. Simply put, a job is a request to process data in the background. Jobs are picked up automatically by microservices containers.
![Admin jobs](./img/admin-jobs.png)
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
:::info
Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library.
:::
<img src={require('./img/admin-jobs.png').default} width="80%" title="Admin jobs" />
@@ -1,32 +0,0 @@
# Password Login
An overview of password login and related settings for Immich.
## Enable/Disable
Immich supports password login, which is enabled by default. The preferred way to disable it is via the [Administration Page](#administration-page), although it can also be changed via a [Server Command](#server-command) as well.
### Administration Page
To toggle the password login setting via the web, navigate to the "Administration", expand "Password Authentication", toggle the "Enabled" switch, and press "Save".
![Password Login Settings](./img/password-login-settings.png)
### Server Command
There are two [Server Commands](/docs/administration/server-commands.md) for password login:
1. `enable-password-login`
2. `disable-password-login`
See [Server Commands](/docs/administration/server-commands.md) for more details about how to run them.
## Password Reset
### Admin
To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/administration/server-commands.md).
### User
Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/administration/user-management.mdx#password-reset) for more information about how to do this.
@@ -0,0 +1,54 @@
# Preparing a pre-existing Postgres server
While not officially recommended, it is possible to run Immich using a pre-existing Postgres server. To use this setup, you should have a baseline level of familiarity with Postgres and the Linux command line. If you do not have these, we recommend using the default setup with a dedicated Postgres container.
By default, Immich expects superuser permission on the Postgres database and requires certain extensions to be installed. This guide outlines the steps required to prepare a pre-existing Postgres server to be used by Immich.
:::tip
Running with a pre-existing Postgres server can unlock powerful administrative features, including logical replication, data page checksums, and streaming write-ahead log backups using programs like pgBackRest or Barman.
:::
## Prerequisites
You must install pgvecto.rs using their [instructions](https://docs.pgvecto.rs/getting-started/installation.html). After installation, add `shared_preload_libraries = 'vectors.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vectors.so'`.
:::note
Make sure the installed version of pgvecto.rs is compatible with your version of Immich. For example, if your Immich version uses the dedicated database image `tensorchord/pgvecto-rs:pg14-v0.2.1`, you must install pgvecto.rs `>= 0.2.1, < 0.3.0`.
:::
## Specifying the connection URL
You can connect to your pre-existing Postgres server by setting the `DB_URL` environment variable in the `.env` file.
```
DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename'
# require a SSL connection to Postgres
# DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename?sslmode=require'
# require a SSL connection, but don't enforce checking the certificate name
# DB_URL='postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename?sslmode=require&sslmode=no-verify'
```
## Without superuser permissions
### Initial installation
Immich can run without superuser permissions by following the below instructions at the `psql` prompt to prepare the database.
```sql title="Set up Postgres for Immich"
CREATE DATABASE <immichdatabasename>;
\c <immichdatabasename>
BEGIN;
ALTER DATABASE <immichdatabasename> OWNER TO <immichdbusername>;
CREATE EXTENSION vectors;
CREATE EXTENSION earthdistance CASCADE;
ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors;
GRANT USAGE ON SCHEMA vectors TO <immichdbusername>;
GRANT SELECT ON TABLE pg_vector_index_stat to <immichdbusername>;
COMMIT;
```
### Updating pgvecto.rs
When installing a new version of pgvecto.rs, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vectors UPDATE;`.
+31
View File
@@ -0,0 +1,31 @@
# Repair Page
The repair page is designed to give information to the system administrator about files that are not tracked, or offline paths.
## Natural State
In this situation, everything is in its place and there is no problem that the system administrator should be aware of.
<img src={require('./img/repair-page.png').default} title="server statistic" />
## Any Other Situation
:::note RAM Usage
Several users report a situation where the page fails to load. In order to solve this problem you should try to allocate more RAM to Immich, if the problem continues, you should stop using the reverse proxy while loading the page.
:::
In any other situation, there are 3 different options that can appear:
- MATCHES - These files are matched by their checksums.
- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file).
:::tip
To get rid of Offline paths you can follow this [guide](/docs/guides/remove-offline-files.md)
:::
- UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug.
In addition, you can download the information from a page, mark everything (in order to check hashing) and correct the problem if a match is found in the hashing.
<img src={require('./img/repair-page-1.png').default} title="server statistic" />
+28 -18
View File
@@ -1,29 +1,41 @@
# Reverse Proxy
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
:::note
The Repair page can take a long time to load. To avoid server timeouts or errors, we recommend specifying a timeout of at least 10 minutes on your proxy server.
:::
### Nginx example config
Below is an example config for nginx. Make sure to include `client_max_body_size 50000M;` also in a `http` block in `/etc/nginx/nginx.conf`.
Below is an example config for nginx. Make sure to set `public_url` to the front-facing URL of your instance, and `backend_url` to the path of the Immich server.
```nginx
server {
server_name <snip>
server_name <public_url>;
# allow large file uploads
client_max_body_size 50000M;
location / {
proxy_pass http://<snip>:2283;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Set headers
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
# enable websockets: http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
# set timeout
proxy_read_timeout 600s;
proxy_send_timeout 600s;
send_timeout 600s;
location / {
proxy_pass http://<backend_url>:2283;
}
}
```
@@ -42,15 +54,13 @@ immich.example.org {
Below is an example config for Apache2 site configuration.
```
```ApacheConf
<VirtualHost *:80>
ServerName <snip>
ProxyRequests Off
# set timeout in seconds
ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
ProxyPassReverse / http://127.0.0.1:2283/
ProxyPreserveHost On
</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.
+13
View File
@@ -0,0 +1,13 @@
# Server Stats
Server statistics to show the total number of videos, photos, and usage per user.
:::info
If a storage quota has been defined for the user, the usage number will be displayed as a percentage of the total storage quota allocated to him.
:::
:::info External library
External library is not included in the storage quota.
:::
<img src={require('./img/server-stats.png').default} title="server statistic" />
+173
View File
@@ -0,0 +1,173 @@
# System Settings
On the system settings page, the administrator can manage global settings for the Immich instance.
:::note
Viewing and modifying the system settings is restricted to the Administrator.
:::
:::tip
You can always return to the default settings by clicking the `Reset to default` button.
:::
## Job Settings
Using these settings, you can determine the amount of work that will run concurrently for each task in microservices. Some tasks can be set to higher values on computers with powerful hardware and storage with good I/O capabilities.
With higher concurrency, the host will work on more assets in parallel,
this advice improves throughput, not latency, for example, it will make Smart Search jobs process more quickly, but it won't make searching faster.
It is important to remember that jobs like Smart Search, Face Detection, Facial Recognition, and Transcode Videos require a **lot** of processing power and therefore do not exaggerate the amount of jobs because you're probably thoroughly overloading the server.
:::info Facial Recognition Concurrency
The Facial Recognition Concurrency value cannot be changed because
[DBSCAN](https://www.youtube.com/watch?v=RDZUdRSDOok) is traditionally sequential, but there are parallel implementations of it out there. Our implementation isn't parallel.
:::
## External Library
### Library watching (EXPERIMENTAL)
External libraries can automatically import changed files without a full rescan. It will import the file whenever the operating system reports a file change. If your photos are mounted over the network, this does not work.
### Periodic Scanning
You can define a custom interval for the trigger external library rescan under Administration -> Settings -> Library.
You can set the scanning interval using the preset or cron format. For more information please refer to e.g. [Crontab Guru](https://crontab.guru/).
## Logging
By default logs are set to record at the log level, the network administrator can choose a deeper or lower level of logs according to his decision or according to the needs required by the Immich support team.
Here you can [learn about the different error levels](https://sematext.com/blog/logging-levels/).
## Machine Learning Settings
Through this setting, you can manage all the settings related to machine learning in Immich, from the setting of remote machine learning to the model and its parameters
You can choose to disable a certain type of machine learning, for example smart search or facial recognition.
### Smart Search
The smart search settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the
Smart Search job on all images to fully apply the change.
:::info Internet connection
Changing models requires a connection to the Internet to download the model.
After downloading, there is no need for Immich to connect to the network
Unless version checking has been enabled in the settings.
:::
### Facial Recognition
Under these settings, you can change the facial recognition settings
Editable settings:
- **Facial Recognition Model -** Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model.
- **Min Detection Score -** Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives.
- **Max Recognition Distance -** Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible.
- **Min Recognized Faces -** The minimum number of recognized faces for a person to be created (AKA: Core face). Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person.
:::info
When changing the values in Min Detection Score, Max Recognition Distance, and Min Recognized Faces.
You will have to restart **only** the job FACIAL RECOGNITION - ALL.
If you replace the Facial Recognition Model, you will have to run the job FACE DETECTION - ALL.
:::
:::tip identical twins
If you have twins, you might want to lower the Max Recognition Distance value, decreasing this a **bit** can make it distinguish between them.
:::
## Map & GPS Settings
### Map Settings
In these settings, you can change the appearance of the map in night and day modes according to your personal preference and according to the supported options.
The map can be adjusted via [OpenMapTiles](https://openmaptiles.org/styles/) for example.
### Reverse Geocoding Settings
Immich supports [Reverse Geocoding](/docs/features/reverse-geocoding) using data from the [GeoNames](https://www.geonames.org/) geographical database.
## OAuth Authentication
Immich supports OAuth Authentication. Read more about this feature and its configuration [here](/docs/administration/oauth).
## Password Authentication
The administrator can choose to disable login with username and password for the entire instance. This means that **no one**, including the system administrator, will be able to log using this method. If [OAuth Authentication](/docs/administration/oauth) is also disabled, no users will be able to login using **any** method. Changing this setting does not affect existing sessions, just new login attempts.
:::tip
You can always use the [Server CLI](/docs/administration/server-commands) to re-enable password login.
:::
## Server Settings
### External Domain
When set, will override the domain name used when viewing and copying a shared link.
### Welcome Message
The administrator can set a custom message on the login screen (the message will be displayed to all users).
## Storage Template
Immich supports a custom [Storage Template](/docs/administration/storage-template). Learn more about this feature and its configuration [here](/docs/administration/storage-template).
## Theme Settings
You can write custom CSS that will get loaded in the web application for all users. This enables administrators to change fonts, colors, and other styles.
For example:
```css title='Custom CSS'
p {
color: green;
}
```
## Thumbnail Settings
By default Immich creates 3 thumbnails for each asset,
Blurred (thumbhash) , Small (webp) , and Large (jpeg), using these settings you can change the quality for the thumbnail files that are created.
**Small thumbnail resolution**
Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.
**Large thumbnail resolution**
Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.
**Quality**
Thumbnail quality from 1-100. Higher is better for quality but produces larger files.
**Prefer wide gamut**
Use display p3 for thumbnails. This better preserves the vibrance of images with wide color spaces, but images may appear differently on old devices with an old browser version. Srgb images are kept as srgb to avoid color shifts.
:::tip
The default resolution for Large thumbnails can be lowered from 1440p (default) to 1080p or 720p to save storage space.
:::
## Trash Settings
In the system administrator's option to set a trash for deleted files, these files will remain in the trash until the deletion date 30 days (default) or as defined by the system administrator.
The trash can be disabled, however this is not recommended as future files that are deleted will be permanently deleted.
:::tip Keyboard shortcut for permanently deletion
You can select assets and press Ctrl + Del from the timeline for quick permanent deletion without the trash option.
:::
## User Settings
### Delete delay
The system administrator can choose to delete users through the administration panel, the system administrator can delete users immediately or alternatively delay the deletion for users (7 days by default) this action permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.
## Version Check
When this option is enabled the `immich-server` will periodically make requests to GitHub to check for new releases.
## Video Transcoding Settings
The system administrator can define parameters according to which video files will be converted to different formats (depending on the settings). The settings can be changed in depth, to learn more about the terminology used here, refer to FFmpeg documentation for [H.264](https://trac.ffmpeg.org/wiki/Encode/H.264) codec, [HEVC](https://trac.ffmpeg.org/wiki/Encode/H.265) codec and [VP9](https://trac.ffmpeg.org/wiki/Encode/VP9) codec.
+26 -22
View File
@@ -1,6 +1,6 @@
# The Immich CLI
Immich has a CLI that allows you to perform certain actions from the command line. This CLI replaces the [legacy CLI](https://github.com/immich-app/CLI) that was previously available. The CLI is hosted in the [cli folder of the the main Immich github repository](https://github.com/immich-app/immich/tree/main/cli).
Immich has a command line interface (CLI) that allows you to perform certain actions from the command line.
## Features
@@ -54,16 +54,19 @@ Usage: immich [options] [command]
Command line interface for Immich
Options:
-V, --version output the version number
-d, --config Configuration directory (env: IMMICH_CONFIG_DIR)
-h, --help display help for command
-V, --version output the version number
-d, --config-directory <directory> Configuration directory where auth.yml will be stored (default: "~/.config/immich/", env:
IMMICH_CONFIG_DIR)
-u, --url [url] Immich server URL (env: IMMICH_INSTANCE_URL)
-k, --key [key] Immich API key (env: IMMICH_API_KEY)
-h, --help display help for command
Commands:
upload [options] [paths...] Upload assets
server-info Display server information
login-key [instanceUrl] [apiKey] Login using an API key
logout Remove stored credentials
help [command] display help for command
login|login-key <url> <key> Login using an API key
logout Remove stored credentials
server-info Display server information
upload [options] [paths...] Upload assets
help [command] display help for command
```
## Commands
@@ -71,23 +74,24 @@ Commands:
The upload command supports the following options:
```
Usage: immich upload [options] [paths...]
Usage: immich upload [paths...] [options]
Upload assets
Arguments:
paths One or more paths to assets to be uploaded
paths One or more paths to assets to be uploaded
Options:
-r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE)
-i, --ignore [paths...] Paths to ignore (env: IMMICH_IGNORE_PATHS)
-h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH)
-H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN)
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
-A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME)
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
--help display help for command
-r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE)
-i, --ignore [paths...] Paths to ignore (default: [], env: IMMICH_IGNORE_PATHS)
-h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH)
-H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN)
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
-A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME)
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
-c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY)
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
--help display help for command
```
Note that the above options can read from environment variables as well.
@@ -97,13 +101,13 @@ Note that the above options can read from environment variables as well.
You begin by authenticating to your Immich server.
```bash
immich login-key [instanceUrl] [apiKey]
immich login [url] [key]
```
For instance,
```bash
immich login-key http://192.168.1.216:2283/api HFEJ38DNSDUEG
immich login http://192.168.1.216:2283/api HFEJ38DNSDUEG
```
This will store your credentials in a `auth.yml` file in the configuration directory which defaults to `~/.config/`. The directory can be set with the `-d` option or the environment variable `IMMICH_CONFIG_DIR`. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually.
+1 -1
View File
@@ -6,7 +6,7 @@ Immich can ingest XMP sidecars on file upload (via the CLI) as well as detect ne
XMP sidecars are external XML files that contain metadata related to media files. Many applications read and write these files either exclusively or in addition to the metadata written to image files. They can be a powerful tool for editing and storing metadata of a media file without modifying the media file itself. When Immich receives or detects an XMP sidecar for a media file, it will attempt to extract the metadata from both the sidecar as well as the media file. It will prioritize the metadata for fields in the sidecar but will fall back and use the metadata in the media file if necessary.
When importing files via the CLI bulk uploader, Immich will automatically detect XMP sidecar files as files that exist next to the original media file and have the exact same name with an additional `.xmp` file extension (i.e., `PXL_20230401_203352928.MP.jpg` and `PXL_20230401_203352928.MP.jpg.xmp`).
When importing files via the CLI bulk uploader or parsing photo metadata for external libraries, Immich will automatically detect XMP sidecar files as files that exist next to the original media file. Immich will look files that have the same name as the photo, but with the `.xmp` file extension. The same name can either include the photo's file extension or without the photo's file extension. For example, for a photo named `PXL_20230401_203352928.MP.jpg`, Immich will look for an XMP file named either `PXL_20230401_203352928.MP.jpg.xmp` or `PXL_20230401_203352928.MP.xmp`. If both `PXL_20230401_203352928.MP.jpg.xmp` and `PXL_20230401_203352928.MP.xmp` are present, Immich will prefer `PXL_20230401_203352928.MP.jpg.xmp`.
There are 2 administrator jobs associated with sidecar files: `SYNC` and `DISCOVER`. The sync job will re-scan all media with existing sidecar files and queue them for a metadata refresh. This is a great use case when third-party applications are used to modify the metadata of media. The discover job will attempt to scan the filesystem for new sidecar files for all media that does not currently have a sidecar file associated with it.
+1 -1
View File
@@ -56,4 +56,4 @@ A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn
### Cons
- Complex configuration
- Depending on your configuration, both the Immich web interface and API may be exposed to the internet. Immich is under very active developement and the existence of severe security vulnerabilities cannot be ruled out.
- Depending on your configuration, both the Immich web interface and API may be exposed to the internet. Immich is under very active development and the existence of severe security vulnerabilities cannot be ruled out.
+3 -3
View File
@@ -21,7 +21,7 @@ cd ./immich-app
Download [`docker-compose.yml`][compose-file] and [`example.env`][env-file], either by running the following commands:
```bash title="Get docker-compose.yml file"
wget https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
```
```bash title="Get .env file"
@@ -29,11 +29,11 @@ wget -O .env https://github.com/immich-app/immich/releases/latest/download/examp
```
```bash title="(Optional) Get hwaccel.transcoding.yml file"
wget https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml
wget -O hwaccel.transcoding.yml https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml
```
```bash title="(Optional) Get hwaccel.ml.yml file"
wget https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml
wget -O hwaccel.ml.yml https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml
```
or by downloading from your browser and moving the files to the directory that you created.
+2 -4
View File
@@ -41,11 +41,9 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_REVERSE_GEOCODING_ROOT` | Path of reverse geocoding dump directory | `/usr/src/resources` | microservices |
:::tip
`TZ` should be set to a `TZ identifier` from [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). For example, `TZ="Etc/UTC"`.
`TZ` is only used by the `exiftool` as a fallback in case the timezone cannot be determined from the image metadata.
`exiftool` is only present in the microservices container.
`TZ` is only used by `exiftool`, which is present in the microservices container, as a fallback in case the timezone cannot be determined from the image metadata.
:::
## Ports
+1 -1
View File
@@ -1,7 +1,7 @@
Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level.
:::note new version
On new machines running version 1.92.0 storage template engine is off by default, for [more info](https://github.com/immich-app/immich/releases#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template,-We%20have%20further).
On new machines running version 1.92.0 storage template engine is off by default, for [more info](https://github.com/immich-app/immich/releases/tag/v1.92.0#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template,-We%20have%20further).
:::
:::tip
+17 -17
View File
@@ -4264,9 +4264,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.4.18",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz",
"integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==",
"version": "10.4.19",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
"funding": [
{
"type": "opencollective",
@@ -4283,7 +4283,7 @@
],
"dependencies": {
"browserslist": "^4.23.0",
"caniuse-lite": "^1.0.30001591",
"caniuse-lite": "^1.0.30001599",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.0.0",
@@ -4728,9 +4728,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001597",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz",
"integrity": "sha512-7LjJvmQU6Sj7bL0j5b5WY/3n7utXUJvAe1lxhsHDbLmwX9mdL86Yjtr+5SRCyf8qME4M7pU2hswj0FpyBVCv9w==",
"version": "1.0.30001600",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz",
"integrity": "sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==",
"funding": [
{
"type": "opencollective",
@@ -12691,9 +12691,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"funding": [
{
"type": "opencollective",
@@ -12711,7 +12711,7 @@
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
"source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -15295,9 +15295,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"engines": {
"node": ">=0.10.0"
}
@@ -16141,9 +16141,9 @@
}
},
"node_modules/typescript": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+1
View File
@@ -24,3 +24,4 @@
/docs/features/user-management /docs/administration/user-management 301
/docs/developer/contributing /docs/developer/pr-checklist 301
/docs/guides/machine-learning /docs/guides/remote-machine-learning 301
/docs/administration/password-login /docs/administration/system-settings 301
+63 -63
View File
@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.99.0",
"version": "1.100.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.99.0",
"version": "1.100.0",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
@@ -38,7 +38,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.1.0",
"version": "2.2.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -80,7 +80,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.99.0",
"version": "1.100.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -1158,9 +1158,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.11.28",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.28.tgz",
"integrity": "sha512-M/GPWVS2wLkSkNHVeLkrF2fD5Lx5UC4PxA0uZcKc6QqbIQUJyW1jVjueJYi1z8n0I5PxYrtpnPnWglE+y9A0KA==",
"version": "20.11.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -1173,9 +1173,9 @@
"dev": true
},
"node_modules/@types/pg": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.2.tgz",
"integrity": "sha512-G2Mjygf2jFMU/9hCaTYxJrwdObdcnuQde1gndooZSOHsNSaCehAuwc7EIuSA34Do8Jx2yZ19KtvW8P0j4EuUXw==",
"version": "8.11.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.4.tgz",
"integrity": "sha512-yw3Bwbda6vO+NvI1Ue/YKOwtl31AYvvd/e73O3V4ZkNzuGpTDndLSyc0dQRB2xrQqDePd20pEGIfqSp/GH3pRw==",
"dev": true,
"dependencies": {
"@types/node": "*",
@@ -1277,16 +1277,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.2.0.tgz",
"integrity": "sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz",
"integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/type-utils": "7.2.0",
"@typescript-eslint/utils": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/type-utils": "7.3.1",
"@typescript-eslint/utils": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -1295,7 +1295,7 @@
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1312,19 +1312,19 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
"integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz",
"integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/typescript-estree": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"debug": "^4.3.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1340,16 +1340,16 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz",
"integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz",
"integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0"
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1357,18 +1357,18 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz",
"integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz",
"integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/utils": "7.2.0",
"@typescript-eslint/typescript-estree": "7.3.1",
"@typescript-eslint/utils": "7.3.1",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1384,12 +1384,12 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz",
"integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz",
"integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1397,13 +1397,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz",
"integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz",
"integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/visitor-keys": "7.3.1",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1412,7 +1412,7 @@
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1449,21 +1449,21 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz",
"integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz",
"integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/scope-manager": "7.3.1",
"@typescript-eslint/types": "7.3.1",
"@typescript-eslint/typescript-estree": "7.3.1",
"semver": "^7.5.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -1474,16 +1474,16 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz",
"integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz",
"integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/types": "7.3.1",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -4790,9 +4790,9 @@
}
},
"node_modules/typescript": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.99.0",
"version": "1.100.0",
"description": "",
"main": "index.js",
"type": "module",
-141
View File
@@ -5,7 +5,6 @@ import {
LibraryResponseDto,
LoginResponseDto,
SharedLinkType,
TimeBucketSize,
getAllLibraries,
getAssetInfo,
updateAssets,
@@ -942,146 +941,6 @@ describe('/asset', () => {
});
});
describe('GET /asset/time-buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get time buckets by month', async () => {
const { status, body } = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month });
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]),
);
});
it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: user1Assets.map(({ id }) => id),
});
const { status, body } = await request(app)
.get('/asset/time-buckets')
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get time buckets by day', async () => {
const { status, body } = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Day });
expect(status).toBe(200);
expect(body).toEqual([
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]);
});
});
describe('GET /asset/time-bucket', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/time-bucket').query({
size: TimeBucketSize.Month,
timeBucket: '1900-01-01T00:00:00.000Z',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should handle 5 digit years', async () => {
const { status, body } = await request(app)
.get('/asset/time-bucket')
.query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
// TODO enable date string validation while still accepting 5 digit years
// it('should fail if time bucket is invalid', async () => {
// const { status, body } = await request(app)
// .get('/asset/time-bucket')
// .set('Authorization', `Bearer ${user1.accessToken}`)
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest);
// });
it('should return time bucket', async () => {
const { status, body } = await request(app)
.get('/asset/time-bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' });
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and favorite', async () => {
const req1 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and trash', async () => {
const req = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest());
});
});
describe('GET /asset', () => {
it('should return stack data', async () => {
const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`);
+122 -14
View File
@@ -1,4 +1,4 @@
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk';
import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
@@ -7,7 +7,6 @@ import { errorDto } from 'src/responses';
import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const today = DateTime.now();
describe('/search', () => {
@@ -19,7 +18,7 @@ describe('/search', () => {
let assetCyclamen: AssetFileUploadResponseDto;
let assetNotocactus: AssetFileUploadResponseDto;
let assetSilver: AssetFileUploadResponseDto;
// let assetDensity: AssetFileUploadResponseDto;
let assetDensity: AssetFileUploadResponseDto;
// let assetPhiladelphia: AssetFileUploadResponseDto;
// let assetOrychophragmus: AssetFileUploadResponseDto;
// let assetRidge: AssetFileUploadResponseDto;
@@ -79,6 +78,37 @@ describe('/search', () => {
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
}
// note: the coordinates here are not the actual coordinates of the images and are random for most of them
const cities = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 63.0695, longitude: -151.0074 }, // denali
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
{ latitude: 5.556_02, longitude: -0.1969 }, // accra
{ latitude: 37.544_270_6, longitude: -4.727_752_8 }, // andalusia
{ latitude: 23.133_02, longitude: -82.383_04 }, // havana
{ latitude: 41.694_11, longitude: 44.833_68 }, // tbilisi
{ latitude: 31.222_22, longitude: 121.458_06 }, // shanghai
{ latitude: 47.040_57, longitude: 9.068_04 }, // glarus
{ latitude: 38.9711, longitude: -109.7137 }, // thompson springs
{ latitude: 40.714_27, longitude: -74.005_97 }, // new york
{ latitude: 32.771_52, longitude: -89.116_73 }, // philadelphia
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
];
const updates = assets.map((asset, i) =>
updateAsset({ id: asset.id, updateAssetDto: cities[i] }, { headers: asBearerAuth(admin.accessToken) }),
);
await Promise.all(updates);
for (const asset of assets) {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
}
[
assetFalcon,
assetDenali,
@@ -92,7 +122,7 @@ describe('/search', () => {
assetOneJpg5,
assetGlarus,
assetSprings,
// assetDensity,
assetDensity,
// assetPhiladelphia,
// assetOrychophragmus,
// assetRidge,
@@ -106,7 +136,7 @@ describe('/search', () => {
});
afterAll(async () => {
await utils.disconnectWebsocket(websocket);
utils.disconnectWebsocket(websocket);
});
describe('POST /search/metadata', () => {
@@ -298,15 +328,15 @@ describe('/search', () => {
},
{
should: 'should search by city',
deferred: () => ({ dto: { city: 'Ralston' }, assets: [assetHeic] }),
deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }),
},
{
should: 'should search by state',
deferred: () => ({ dto: { state: 'Douglas County, Nebraska' }, assets: [assetHeic] }),
deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }),
},
{
should: 'should search by country',
deferred: () => ({ dto: { country: 'United States of America' }, assets: [assetHeic] }),
deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }),
},
{
should: 'should search by make',
@@ -370,13 +400,44 @@ describe('/search', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should get places', async () => {
it('should get relevant places', async () => {
const name = 'Paris';
const { status, body } = await request(app)
.get('/search/places?name=Paris')
.get(`/search/places?name=${name}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThan(10);
if (Array.isArray(body)) {
expect(body.length).toBeGreaterThan(10);
expect(body[0].name).toEqual(name);
expect(body[0].admin2name).toEqual(name);
}
});
});
describe('GET /search/cities', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/search/cities');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get all cities', async () => {
const { status, body } = await request(app)
.get('/search/cities')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(Array.isArray(body)).toBe(true);
if (Array.isArray(body)) {
expect(body.length).toBeGreaterThan(10);
const assetsWithCity = body.filter((asset) => !!asset.exifInfo?.city);
expect(assetsWithCity.length).toEqual(body.length);
const cities = new Set(assetsWithCity.map((asset) => asset.exifInfo.city));
expect(cities.size).toEqual(body.length);
}
});
});
@@ -391,7 +452,21 @@ describe('/search', () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=country')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(['United States of America']);
expect(body).toEqual([
'Cuba',
'France',
'Georgia',
'Germany',
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
]);
expect(status).toBe(200);
});
@@ -399,7 +474,23 @@ describe('/search', () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=state')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(['Douglas County, Nebraska', 'Mesa County, Colorado']);
expect(body).toEqual([
'Accra, Greater Accra',
'Berlin',
'Glarus, Glarus',
'Havana',
'Marrakech, Marrakesh-Safi',
'Mesa County, Colorado',
'Neshoba County, Mississippi',
'New York',
'Page County, Virginia',
'Paris, Île-de-France',
'Province of Córdoba, Andalusia',
'Shanghai Municipality, Shanghai',
'St.-Petersburg',
'Tbilisi',
'Tokyo',
]);
expect(status).toBe(200);
});
@@ -407,7 +498,24 @@ describe('/search', () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=city')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(['Palisade', 'Ralston']);
expect(body).toEqual([
'Accra',
'Berlin',
'Glarus',
'Havana',
'Marrakesh',
'Montalbán de Córdoba',
'New York City',
'Palisade',
'Paris',
'Philadelphia',
'Saint Petersburg',
'Shanghai',
'Singapore',
'Stanley',
'Tbilisi',
'Tokyo',
]);
expect(status).toBe(200);
});
+17 -1
View File
@@ -1,4 +1,4 @@
import { LoginResponseDto, getConfig } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, getConfig } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
@@ -10,11 +10,14 @@ const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAu
describe('/system-config', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetFileUploadResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
asset = await utils.createAsset(admin.accessToken);
});
describe('GET /system-config/map/style.json', () => {
@@ -24,6 +27,19 @@ describe('/system-config', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app)
.get(`/system-config/map/style.json?key=${sharedLink.key}`)
.query({ theme: 'dark' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
+193
View File
@@ -0,0 +1,193 @@
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
import { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
// TODO this should probably be a test util function
const today = DateTime.fromObject({
year: 2023,
month: 11,
day: 3,
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
describe('/timeline', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let timeBucketUser: LoginResponseDto;
let userAssets: AssetFileUploadResponseDto[];
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[user, timeBucketUser] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.create('1')),
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
]);
userAssets = await Promise.all([
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken, {
isFavorite: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
await Promise.all([
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
]);
});
describe('GET /timeline/buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get time buckets by month', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month });
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]),
);
});
it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user.accessToken, {
type: SharedLinkType.Individual,
assetIds: userAssets.map(({ id }) => id),
});
const { status, body } = await request(app)
.get('/timeline/buckets')
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get time buckets by day', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Day });
expect(status).toBe(200);
expect(body).toEqual([
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and favorite', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and trash', async () => {
const req = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest());
});
});
describe('GET /timeline/bucket', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/bucket').query({
size: TimeBucketSize.Month,
timeBucket: '1900-01-01T00:00:00.000Z',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should handle 5 digit years', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
// TODO enable date string validation while still accepting 5 digit years
// it('should fail if time bucket is invalid', async () => {
// const { status, body } = await request(app)
// .get('/timeline/bucket')
// .set('Authorization', `Bearer ${user.accessToken}`)
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest);
// });
it('should return time bucket', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' });
expect(status).toBe(200);
expect(body).toEqual([]);
});
});
});
+6 -6
View File
@@ -2,25 +2,25 @@ import { stat } from 'node:fs/promises';
import { app, immichCli, utils } from 'src/utils';
import { beforeEach, describe, expect, it } from 'vitest';
describe(`immich login-key`, () => {
describe(`immich login`, () => {
beforeEach(async () => {
await utils.resetDatabase();
});
it('should require a url', async () => {
const { stderr, exitCode } = await immichCli(['login-key']);
const { stderr, exitCode } = await immichCli(['login']);
expect(stderr).toBe("error: missing required argument 'url'");
expect(exitCode).toBe(1);
});
it('should require a key', async () => {
const { stderr, exitCode } = await immichCli(['login-key', app]);
const { stderr, exitCode } = await immichCli(['login', app]);
expect(stderr).toBe("error: missing required argument 'key'");
expect(exitCode).toBe(1);
});
it('should require a valid key', async () => {
const { stderr, exitCode } = await immichCli(['login-key', app, 'immich-is-so-cool']);
const { stderr, exitCode } = await immichCli(['login', app, 'immich-is-so-cool']);
expect(stderr).toContain('Failed to connect to server');
expect(stderr).toContain('Invalid API key');
expect(stderr).toContain('401');
@@ -30,7 +30,7 @@ describe(`immich login-key`, () => {
it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken);
const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283/api',
'Logged in as admin@immich.cloud',
@@ -47,7 +47,7 @@ describe(`immich login-key`, () => {
it('should login without /api in the url', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken);
const { stdout, stderr, exitCode } = await immichCli(['login-key', app.replaceAll('/api', ''), `${key.secret}`]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283',
'Discovered API at http://127.0.0.1:2283/api',
+12 -8
View File
@@ -4,19 +4,23 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe(`immich server-info`, () => {
beforeAll(async () => {
await utils.resetDatabase();
await utils.cliLogin();
const admin = await utils.adminSetup();
await utils.cliLogin(admin.accessToken);
});
it('should return the server info', async () => {
const { stderr, stdout, exitCode } = await immichCli(['server-info']);
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Server Version:'),
expect.stringContaining('Image Types:'),
expect.stringContaining('Video Types:'),
'Statistics:',
' Images: 0',
' Videos: 0',
' Total: 0',
expect.stringContaining('Server Info (via admin@immich.cloud'),
' Url: http://127.0.0.1:2283/api',
expect.stringContaining('Version:'),
' Formats:',
expect.stringContaining('Images:'),
expect.stringContaining('Videos:'),
' Statistics:',
' Images: 0',
' Videos: 0',
' Total: 0',
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
+51 -2
View File
@@ -1,20 +1,69 @@
import { getAllAlbums, getAllAssets } from '@immich/sdk';
import { LoginResponseDto, getAllAlbums, getAllAssets } from '@immich/sdk';
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich upload`, () => {
let admin: LoginResponseDto;
let key: string;
beforeAll(async () => {
await utils.resetDatabase();
key = await utils.cliLogin();
admin = await utils.adminSetup();
key = await utils.cliLogin(admin.accessToken);
});
beforeEach(async () => {
await utils.resetDatabase(['assets', 'albums']);
});
describe(`immich upload /path/to/file.jpg`, () => {
it('should upload a single file', async () => {
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
);
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(1);
});
it('should skip a duplicate file', async () => {
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(first.stderr).toBe('');
expect(first.stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
);
expect(first.exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(1);
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(second.stderr).toBe('');
expect(second.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Found 0 new files and 1 duplicate'),
expect.stringContaining('All assets were already uploaded, nothing to do'),
]),
);
expect(first.exitCode).toBe(0);
});
it('should skip files that do not exist', async () => {
const { stderr, stdout, exitCode } = await immichCli(['upload', `/path/to/file`]);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(expect.arrayContaining([expect.stringContaining('No files found, exiting')]));
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(0);
});
});
describe('immich upload --recursive', () => {
it('should upload a folder recursively', async () => {
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
+6 -5
View File
@@ -39,7 +39,7 @@ import { makeRandomImage } from 'src/generators';
import request from 'supertest';
type CliResponse = { stdout: string; stderr: string; exitCode: number | null };
type EventType = 'assetUpload' | 'assetDelete' | 'userDelete';
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete';
type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
type AdminSetupOptions = { onboarding?: boolean };
type AssetData = { bytes?: Buffer; filename: string };
@@ -82,6 +82,7 @@ let client: pg.Client | null = null;
const events: Record<EventType, Set<string>> = {
assetUpload: new Set<string>(),
assetUpdate: new Set<string>(),
assetDelete: new Set<string>(),
userDelete: new Set<string>(),
};
@@ -185,6 +186,7 @@ export const utils = {
websocket
.on('connect', () => resolve(websocket))
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
.on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
.connect();
@@ -404,10 +406,9 @@ export const utils = {
},
]),
cliLogin: async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken);
await immichCli(['login-key', app, `${key.secret}`]);
cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken);
await immichCli(['login', app, `${key.secret}`]);
return key.secret;
},
};
+2 -2
View File
@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:991e20a11120277e977cadbc104e7a9b196a68a346597879821b19034285a403 as builder-cpu
FROM python:3.11-bookworm@sha256:e2ed446c899827ed992f8a5a8875fa0853fcab32581e61418b650322061aa3c4 as builder-cpu
FROM openvino/ubuntu22_runtime:2023.3.0@sha256:176646df619032ea6c10faf842867119c393e7497b7f88b5e307e932a0fd5aa8 as builder-openvino
USER root
@@ -36,7 +36,7 @@ RUN python3 -m venv /opt/venv
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
FROM python:3.11-slim-bookworm@sha256:a2eb07f336e4f194358382611b4fea136c632b40baa6314cb27a366deeaf0144 as prod-cpu
FROM python:3.11-slim-bookworm@sha256:90f8795536170fd08236d2ceb74fe7065dbf74f738d8b84bfbf263656654dc9b as prod-cpu
FROM openvino/ubuntu22_runtime:2023.3.0@sha256:176646df619032ea6c10faf842867119c393e7497b7f88b5e307e932a0fd5aa8 as prod-openvino
USER root
+1 -11
View File
@@ -12,8 +12,6 @@ from rich.logging import RichHandler
from uvicorn import Server
from uvicorn.workers import UvicornWorker
from .schemas import ModelType
class PreloadModelData(BaseModel):
clip: str | None
@@ -21,7 +19,7 @@ class PreloadModelData(BaseModel):
class Settings(BaseSettings):
cache_folder: str = "/cache"
cache_folder: Path = Path("/cache")
model_ttl: int = 300
model_ttl_poll_s: int = 10
host: str = "0.0.0.0"
@@ -55,14 +53,6 @@ def clean_name(model_name: str) -> str:
return model_name.split("/")[-1].translate(_clean_name)
def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
return Path(settings.cache_folder) / model_type.value / clean_name(model_name)
def get_hf_model_name(model_name: str) -> str:
return f"immich-app/{clean_name(model_name)}"
LOG_LEVELS: dict[str, int] = {
"critical": logging.ERROR,
"error": logging.ERROR,
+31 -31
View File
@@ -6,22 +6,21 @@ import threading
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from functools import partial
from typing import Any, AsyncGenerator, Callable, Iterator
from zipfile import BadZipFile
import orjson
from fastapi import Depends, FastAPI, Form, HTTPException, UploadFile
from fastapi.responses import ORJSONResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from starlette.formparsers import MultiPartParser
from app.models.base import InferenceModel
from .config import PreloadModelData, log, settings
from .models.cache import ModelCache
from .schemas import (
MessageResponse,
ModelTask,
ModelType,
Predictor,
TextResponse,
)
@@ -63,12 +62,21 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
gc.collect()
async def preload_models(preload_models: PreloadModelData) -> None:
log.info(f"Preloading models: {preload_models}")
if preload_models.clip is not None:
await load(await model_cache.get(preload_models.clip, ModelType.CLIP))
if preload_models.facial_recognition is not None:
await load(await model_cache.get(preload_models.facial_recognition, ModelType.FACIAL_RECOGNITION))
async def preload_models(preload: PreloadModelData) -> None:
log.info(f"Preloading models: {preload}")
if preload.clip is not None:
model = await model_cache.get(preload.clip, ModelType.TEXTUAL, ModelTask.SEARCH)
await load(model)
model = await model_cache.get(preload.clip, ModelType.VISUAL, ModelTask.SEARCH)
await load(model)
if preload.facial_recognition is not None:
model = await model_cache.get(preload.facial_recognition, ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)
await load(model)
model = await model_cache.get(preload.facial_recognition, ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION)
await load(model)
def update_state() -> Iterator[None]:
@@ -98,6 +106,7 @@ def ping() -> str:
async def predict(
model_name: str = Form(alias="modelName"),
model_type: ModelType = Form(alias="modelType"),
model_task: ModelTask = Form(alias="modelTask"),
options: str = Form(default="{}"),
text: str | None = Form(default=None),
image: UploadFile | None = None,
@@ -113,39 +122,30 @@ async def predict(
except orjson.JSONDecodeError:
raise HTTPException(400, f"Invalid options JSON: {options}")
model = await load(await model_cache.get(model_name, model_type, ttl=settings.model_ttl, **kwargs))
model.configure(**kwargs)
outputs = await run(model.predict, inputs)
model = await model_cache.get(model_name, model_type, model_task, ttl=settings.model_ttl, **kwargs)
model = await load(model)
outputs = await run(model.predict, inputs, **kwargs)
return ORJSONResponse(outputs)
async def run(func: Callable[..., Any], inputs: Any) -> Any:
async def run(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
if thread_pool is None:
return func(inputs)
return await asyncio.get_running_loop().run_in_executor(thread_pool, func, inputs)
return func(*args, **kwargs)
partial_func = partial(func, *args, **kwargs)
return await asyncio.get_running_loop().run_in_executor(thread_pool, partial_func)
async def load(model: InferenceModel) -> InferenceModel:
async def load(model: Predictor) -> Predictor:
if model.loaded:
return model
def _load(model: InferenceModel) -> None:
def _load(model: Predictor) -> Predictor:
with lock:
model.load()
return model
try:
await run(_load, model)
return model
except (OSError, InvalidProtobuf, BadZipFile, NoSuchFile):
log.warning(
(
f"Failed to load {model.model_type.replace('_', ' ')} model '{model.model_name}'."
"Clearing cache and retrying."
)
)
model.clear_cache()
await run(_load, model)
return model
await run(_load, model)
return model
async def idle_shutdown_task() -> None:
+25 -18
View File
@@ -1,24 +1,31 @@
from typing import Any
from app.schemas import ModelType
from app.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder
from app.models.clip.visual import OpenClipVisualEncoder
from app.schemas import ModelSource, ModelTask, ModelType, Predictor
from .base import InferenceModel
from .clip import MCLIPEncoder, OpenCLIPEncoder
from .constants import is_insightface, is_mclip, is_openclip
from .facial_recognition import FaceRecognizer
from .constants import get_model_source
from .facial_recognition.detection import FaceDetector
from .facial_recognition.recognition import FaceRecognizer
def from_model_type(model_type: ModelType, model_name: str, **model_kwargs: Any) -> InferenceModel:
match model_type:
case ModelType.CLIP:
if is_openclip(model_name):
return OpenCLIPEncoder(model_name, **model_kwargs)
elif is_mclip(model_name):
return MCLIPEncoder(model_name, **model_kwargs)
case ModelType.FACIAL_RECOGNITION:
if is_insightface(model_name):
return FaceRecognizer(model_name, **model_kwargs)
def from_model_type(model_name: str, model_type: ModelType, model_task: ModelTask, **model_kwargs: Any) -> Predictor:
source = get_model_source(model_name)
match source, model_type, model_task:
case ModelSource.OPENCLIP | ModelSource.MCLIP, ModelType.VISUAL, ModelTask.SEARCH:
return OpenClipVisualEncoder(model_name, **model_kwargs)
case ModelSource.OPENCLIP, ModelType.TEXTUAL, ModelTask.SEARCH:
return OpenClipTextualEncoder(model_name, **model_kwargs)
case ModelSource.MCLIP, ModelType.TEXTUAL, ModelTask.SEARCH:
return MClipTextualEncoder(model_name, **model_kwargs)
case ModelSource.INSIGHTFACE, ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION:
return FaceDetector(model_name, **model_kwargs)
case ModelSource.INSIGHTFACE, ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION:
return FaceRecognizer(model_name, **model_kwargs)
case _:
raise ValueError(f"Unknown model type {model_type}")
raise ValueError(f"Unknown {model_type} model {model_name}")
raise ValueError(f"Unknown model combination: {source}, {model_type}, {model_task}")
+47 -24
View File
@@ -3,21 +3,24 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from shutil import rmtree
from typing import Any
from typing import Any, ClassVar
from zipfile import BadZipFile
import onnxruntime as ort
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from huggingface_hub import snapshot_download
import ann.ann
from app.models.constants import SUPPORTED_PROVIDERS
from ..config import get_cache_dir, get_hf_model_name, log, settings
from ..schemas import ModelRuntime, ModelType
from ..config import clean_name, log, settings
from ..schemas import ModelFormat, ModelSession, ModelTask, ModelType
from .ann import AnnSession
class InferenceModel(ABC):
_model_type: ModelType
_model_task: ClassVar[ModelTask]
_model_type: ClassVar[ModelType]
def __init__(
self,
@@ -26,16 +29,16 @@ class InferenceModel(ABC):
providers: list[str] | None = None,
provider_options: list[dict[str, Any]] | None = None,
sess_options: ort.SessionOptions | None = None,
preferred_runtime: ModelRuntime | None = None,
preferred_format: ModelFormat | None = None,
**model_kwargs: Any,
) -> None:
self.loaded = False
self.model_name = model_name
self.model_name = clean_name(model_name)
self.cache_dir = Path(cache_dir) if cache_dir is not None else self.cache_dir_default
self.providers = providers if providers is not None else self.providers_default
self.provider_options = provider_options if provider_options is not None else self.provider_options_default
self.sess_options = sess_options if sess_options is not None else self.sess_options_default
self.preferred_runtime = preferred_runtime if preferred_runtime is not None else self.preferred_runtime_default
self.preferred_runtime = preferred_format if preferred_format is not None else self.preferred_runtime_default
def download(self) -> None:
if not self.cached:
@@ -47,35 +50,47 @@ class InferenceModel(ABC):
def load(self) -> None:
if self.loaded:
return
self.download()
log.info(f"Loading {self.model_type.replace('-', ' ')} model '{self.model_name}' to memory")
self._load()
try:
self.download()
log.info(f"Loading {self.model_type.replace('-', ' ')} model '{self.model_name}' to memory")
self.session = self._load()
except (OSError, InvalidProtobuf, BadZipFile, NoSuchFile):
log.warning(
(
f"Failed to load {self.model_type.replace('_', ' ')} model '{self.model_name}'."
"Clearing cache and retrying."
)
)
self.clear_cache()
self.download()
self.session = self._load()
self.loaded = True
def predict(self, inputs: Any, **model_kwargs: Any) -> Any:
self.load()
if model_kwargs:
self.configure(**model_kwargs)
return self._predict(inputs)
return self._predict(inputs, **model_kwargs)
@abstractmethod
def _predict(self, inputs: Any) -> Any: ...
def _predict(self, inputs: Any, **model_kwargs: Any) -> Any: ...
def configure(self, **model_kwargs: Any) -> None:
def configure(self, **kwargs: Any) -> None:
pass
def _download(self) -> None:
ignore_patterns = [] if self.preferred_runtime == ModelRuntime.ARMNN else ["*.armnn"]
ignore_patterns = [] if self.preferred_runtime == ModelFormat.ARMNN else ["*.armnn"]
snapshot_download(
get_hf_model_name(self.model_name),
f"immich-app/{clean_name(self.model_name)}",
cache_dir=self.cache_dir,
local_dir=self.cache_dir,
local_dir_use_symlinks=False,
ignore_patterns=ignore_patterns,
)
@abstractmethod
def _load(self) -> None: ...
def _load(self) -> ModelSession:
return self._make_session(self.model_path)
def clear_cache(self) -> None:
if not self.cache_dir.exists():
@@ -99,7 +114,7 @@ class InferenceModel(ABC):
self.cache_dir.unlink()
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _make_session(self, model_path: Path) -> AnnSession | ort.InferenceSession:
def _make_session(self, model_path: Path) -> ModelSession:
if not model_path.is_file():
onnx_path = model_path.with_suffix(".onnx")
if not onnx_path.is_file():
@@ -124,6 +139,14 @@ class InferenceModel(ABC):
raise ValueError(f"Unsupported model file type: {model_path.suffix}")
return session
@property
def model_path(self) -> Path:
return self.cache_dir / self.model_type.value / f"model.{self.preferred_runtime}"
@property
def model_task(self) -> ModelTask:
return self._model_task
@property
def model_type(self) -> ModelType:
return self._model_type
@@ -138,11 +161,11 @@ class InferenceModel(ABC):
@property
def cache_dir_default(self) -> Path:
return get_cache_dir(self.model_name, self.model_type)
return settings.cache_folder / self.model_task.value / self.model_name
@property
def cached(self) -> bool:
return self.cache_dir.is_dir() and any(self.cache_dir.iterdir())
return self.model_path.is_file()
@property
def providers(self) -> list[str]:
@@ -226,14 +249,14 @@ class InferenceModel(ABC):
return sess_options
@property
def preferred_runtime(self) -> ModelRuntime:
def preferred_runtime(self) -> ModelFormat:
return self._preferred_runtime
@preferred_runtime.setter
def preferred_runtime(self, preferred_runtime: ModelRuntime) -> None:
def preferred_runtime(self, preferred_runtime: ModelFormat) -> None:
log.debug(f"Setting preferred runtime to {preferred_runtime}")
self._preferred_runtime = preferred_runtime
@property
def preferred_runtime_default(self) -> ModelRuntime:
return ModelRuntime.ARMNN if ann.ann.is_available and settings.ann else ModelRuntime.ONNX
def preferred_runtime_default(self) -> ModelFormat:
return ModelFormat.ARMNN if ann.ann.is_available and settings.ann else ModelFormat.ONNX
+31 -8
View File
@@ -5,9 +5,9 @@ from aiocache.lock import OptimisticLock
from aiocache.plugins import TimingPlugin
from app.models import from_model_type
from app.models.facial_recognition.pipeline import FacialRecognitionPipeline
from ..schemas import ModelType, has_profiling
from .base import InferenceModel
from ..schemas import ModelTask, ModelType, Predictor, has_profiling
class ModelCache:
@@ -31,11 +31,13 @@ class ModelCache:
if profiling:
plugins.append(TimingPlugin())
self.revalidate_enable = revalidate
self.should_revalidate = revalidate
self.cache = SimpleMemoryCache(timeout=timeout, plugins=plugins, namespace=None)
async def get(self, model_name: str, model_type: ModelType, **model_kwargs: Any) -> InferenceModel:
async def get(
self, model_name: str, model_type: ModelType, model_task: ModelTask, **model_kwargs: Any
) -> Predictor:
"""
Args:
model_name: Name of model in the model hub used for the task.
@@ -45,17 +47,38 @@ class ModelCache:
model: The requested model.
"""
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
key = f"{model_name}{model_type.value}{model_task.value}"
async with OptimisticLock(self.cache, key) as lock:
model: InferenceModel | None = await self.cache.get(key)
model: Predictor | None = await self.cache.get(key)
if model is None:
model = from_model_type(model_type, model_name, **model_kwargs)
if model_type == ModelType.PIPELINE:
model = await self._get_pipeline(model_name, model_task, **model_kwargs)
else:
model = from_model_type(model_name, model_type, model_task, **model_kwargs)
await lock.cas(model, ttl=model_kwargs.get("ttl", None))
elif self.revalidate_enable:
elif self.should_revalidate:
await self.revalidate(key, model_kwargs.get("ttl", None))
return model
async def _get_pipeline(self, model_name: str, model_task: ModelTask, **model_kwargs: Any) -> Predictor:
"""
Args:
model_name: Name of model in the model hub used for the task.
model_type: Model type or task, which determines which model zoo is used.
Returns:
model: The requested model.
"""
match model_task:
case ModelTask.FACIAL_RECOGNITION:
det_model: Any = await self.get(model_name, ModelType.DETECTION, model_task, **model_kwargs)
rec_model: Any = await self.get(model_name, ModelType.RECOGNITION, model_task, **model_kwargs)
return FacialRecognitionPipeline(det_model, rec_model)
case _:
raise ValueError(f"Unknown model task: {model_task}")
async def get_profiling(self) -> dict[str, float] | None:
if not has_profiling(self.cache):
return None
-189
View File
@@ -1,189 +0,0 @@
import json
from abc import abstractmethod
from functools import cached_property
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
import numpy as np
from numpy.typing import NDArray
from PIL import Image
from tokenizers import Encoding, Tokenizer
from app.config import clean_name, log
from app.models.transforms import crop, get_pil_resampling, normalize, resize, to_numpy
from app.schemas import ModelType
from .base import InferenceModel
class BaseCLIPEncoder(InferenceModel):
_model_type = ModelType.CLIP
def __init__(
self,
model_name: str,
cache_dir: Path | str | None = None,
mode: Literal["text", "vision"] | None = None,
**model_kwargs: Any,
) -> None:
self.mode = mode
super().__init__(model_name, cache_dir, **model_kwargs)
def _load(self) -> None:
if self.mode == "text" or self.mode is None:
log.debug(f"Loading clip text model '{self.model_name}'")
self.text_model = self._make_session(self.textual_path)
log.debug(f"Loaded clip text model '{self.model_name}'")
if self.mode == "vision" or self.mode is None:
log.debug(f"Loading clip vision model '{self.model_name}'")
self.vision_model = self._make_session(self.visual_path)
log.debug(f"Loaded clip vision model '{self.model_name}'")
def _predict(self, image_or_text: Image.Image | str) -> NDArray[np.float32]:
if isinstance(image_or_text, bytes):
image_or_text = Image.open(BytesIO(image_or_text))
match image_or_text:
case Image.Image():
if self.mode == "text":
raise TypeError("Cannot encode image as text-only model")
outputs: NDArray[np.float32] = self.vision_model.run(None, self.transform(image_or_text))[0][0]
case str():
if self.mode == "vision":
raise TypeError("Cannot encode text as vision-only model")
outputs = self.text_model.run(None, self.tokenize(image_or_text))[0][0]
case _:
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
return outputs
@abstractmethod
def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]:
pass
@abstractmethod
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:
pass
@property
def textual_dir(self) -> Path:
return self.cache_dir / "textual"
@property
def visual_dir(self) -> Path:
return self.cache_dir / "visual"
@property
def model_cfg_path(self) -> Path:
return self.cache_dir / "config.json"
@property
def textual_path(self) -> Path:
return self.textual_dir / f"model.{self.preferred_runtime}"
@property
def visual_path(self) -> Path:
return self.visual_dir / f"model.{self.preferred_runtime}"
@property
def tokenizer_file_path(self) -> Path:
return self.textual_dir / "tokenizer.json"
@property
def tokenizer_cfg_path(self) -> Path:
return self.textual_dir / "tokenizer_config.json"
@property
def preprocess_cfg_path(self) -> Path:
return self.visual_dir / "preprocess_cfg.json"
@property
def cached(self) -> bool:
return self.textual_path.is_file() and self.visual_path.is_file()
@cached_property
def model_cfg(self) -> dict[str, Any]:
log.debug(f"Loading model config for CLIP model '{self.model_name}'")
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
log.debug(f"Loaded model config for CLIP model '{self.model_name}'")
return model_cfg
@cached_property
def tokenizer_file(self) -> dict[str, Any]:
log.debug(f"Loading tokenizer file for CLIP model '{self.model_name}'")
tokenizer_file: dict[str, Any] = json.load(self.tokenizer_file_path.open())
log.debug(f"Loaded tokenizer file for CLIP model '{self.model_name}'")
return tokenizer_file
@cached_property
def tokenizer_cfg(self) -> dict[str, Any]:
log.debug(f"Loading tokenizer config for CLIP model '{self.model_name}'")
tokenizer_cfg: dict[str, Any] = json.load(self.tokenizer_cfg_path.open())
log.debug(f"Loaded tokenizer config for CLIP model '{self.model_name}'")
return tokenizer_cfg
@cached_property
def preprocess_cfg(self) -> dict[str, Any]:
log.debug(f"Loading visual preprocessing config for CLIP model '{self.model_name}'")
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open())
log.debug(f"Loaded visual preprocessing config for CLIP model '{self.model_name}'")
return preprocess_cfg
class OpenCLIPEncoder(BaseCLIPEncoder):
def __init__(
self,
model_name: str,
cache_dir: Path | str | None = None,
mode: Literal["text", "vision"] | None = None,
**model_kwargs: Any,
) -> None:
super().__init__(clean_name(model_name), cache_dir, mode, **model_kwargs)
def _load(self) -> None:
super()._load()
self._load_tokenizer()
size: list[int] | int = self.preprocess_cfg["size"]
self.size = size[0] if isinstance(size, list) else size
self.resampling = get_pil_resampling(self.preprocess_cfg["interpolation"])
self.mean = np.array(self.preprocess_cfg["mean"], dtype=np.float32)
self.std = np.array(self.preprocess_cfg["std"], dtype=np.float32)
def _load_tokenizer(self) -> Tokenizer:
log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'")
text_cfg: dict[str, Any] = self.model_cfg["text_cfg"]
context_length: int = text_cfg.get("context_length", 77)
pad_token: str = self.tokenizer_cfg["pad_token"]
self.tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
pad_id: int = self.tokenizer.token_to_id(pad_token)
self.tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
self.tokenizer.enable_truncation(max_length=context_length)
log.debug(f"Loaded tokenizer for CLIP model '{self.model_name}'")
def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]:
tokens: Encoding = self.tokenizer.encode(text)
return {"text": np.array([tokens.ids], dtype=np.int32)}
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:
image = resize(image, self.size)
image = crop(image, self.size)
image_np = to_numpy(image)
image_np = normalize(image_np, self.mean, self.std)
return {"image": np.expand_dims(image_np.transpose(2, 0, 1), 0)}
class MCLIPEncoder(OpenCLIPEncoder):
def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]:
tokens: Encoding = self.tokenizer.encode(text)
return {
"input_ids": np.array([tokens.ids], dtype=np.int32),
"attention_mask": np.array([tokens.attention_mask], dtype=np.int32),
}
+111
View File
@@ -0,0 +1,111 @@
import json
from abc import abstractmethod
from functools import cached_property
from pathlib import Path
from typing import Any
import numpy as np
from numpy.typing import NDArray
from tokenizers import Encoding, Tokenizer
from app.config import log
from app.schemas import ModelSession, ModelTask, ModelType
from app.models.base import InferenceModel
class BaseCLIPTextualEncoder(InferenceModel):
_model_task = ModelTask.SEARCH
_model_type = ModelType.TEXTUAL
def _predict(self, inputs: str, **kwargs: Any) -> NDArray[np.float32]:
res: NDArray[np.float32] = self.session.run(None, self.tokenize(inputs))[0][0]
return res
def _load(self) -> ModelSession:
log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'")
self.tokenizer = self._load_tokenizer()
log.debug(f"Loaded tokenizer for CLIP model '{self.model_name}'")
return super()._load()
@abstractmethod
def _load_tokenizer(self) -> Tokenizer:
pass
@abstractmethod
def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]:
pass
@property
def model_dir(self) -> Path:
return self.cache_dir / "textual"
@property
def model_cfg_path(self) -> Path:
return self.cache_dir / "config.json"
@property
def model_path(self) -> Path:
return self.model_dir / f"model.{self.preferred_runtime}"
@property
def tokenizer_file_path(self) -> Path:
return self.model_dir / "tokenizer.json"
@property
def tokenizer_cfg_path(self) -> Path:
return self.model_dir / "tokenizer_config.json"
@property
def cached(self) -> bool:
return self.model_path.is_file()
@cached_property
def model_cfg(self) -> dict[str, Any]:
log.debug(f"Loading model config for CLIP model '{self.model_name}'")
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
log.debug(f"Loaded model config for CLIP model '{self.model_name}'")
return model_cfg
@cached_property
def tokenizer_file(self) -> dict[str, Any]:
log.debug(f"Loading tokenizer file for CLIP model '{self.model_name}'")
tokenizer_file: dict[str, Any] = json.load(self.tokenizer_file_path.open())
log.debug(f"Loaded tokenizer file for CLIP model '{self.model_name}'")
return tokenizer_file
@cached_property
def tokenizer_cfg(self) -> dict[str, Any]:
log.debug(f"Loading tokenizer config for CLIP model '{self.model_name}'")
tokenizer_cfg: dict[str, Any] = json.load(self.tokenizer_cfg_path.open())
log.debug(f"Loaded tokenizer config for CLIP model '{self.model_name}'")
return tokenizer_cfg
class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
def _load_tokenizer(self) -> Tokenizer:
text_cfg: dict[str, Any] = self.model_cfg["text_cfg"]
context_length: int = text_cfg.get("context_length", 77)
pad_token: str = self.tokenizer_cfg["pad_token"]
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
pad_id: int = tokenizer.token_to_id(pad_token)
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
tokenizer.enable_truncation(max_length=context_length)
return tokenizer
def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]:
tokens: Encoding = self.tokenizer.encode(text)
return {"text": np.array([tokens.ids], dtype=np.int32)}
class MClipTextualEncoder(OpenClipTextualEncoder):
def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]:
tokens: Encoding = self.tokenizer.encode(text)
return {
"input_ids": np.array([tokens.ids], dtype=np.int32),
"attention_mask": np.array([tokens.attention_mask], dtype=np.int32),
}
@@ -0,0 +1,84 @@
import json
from abc import abstractmethod
from functools import cached_property
from io import BytesIO
from pathlib import Path
from typing import Any
import numpy as np
from numpy.typing import NDArray
from PIL import Image
from app.config import log
from app.models.transforms import crop_pil, get_pil_resampling, normalize, resize_pil, to_numpy
from app.schemas import ModelSession, ModelTask, ModelType
from app.models.base import InferenceModel
class BaseCLIPVisualEncoder(InferenceModel):
_model_task = ModelTask.SEARCH
_model_type = ModelType.VISUAL
def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> NDArray[np.float32]:
if isinstance(inputs, bytes):
inputs = Image.open(BytesIO(inputs))
res: NDArray[np.float32] = self.session.run(None, self.transform(inputs))[0][0]
return res
@abstractmethod
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:
pass
@property
def model_dir(self) -> Path:
return self.cache_dir / "visual"
@property
def model_cfg_path(self) -> Path:
return self.cache_dir / "config.json"
@property
def model_path(self) -> Path:
return self.model_dir / f"model.{self.preferred_runtime}"
@property
def preprocess_cfg_path(self) -> Path:
return self.model_dir / "preprocess_cfg.json"
@property
def cached(self) -> bool:
return self.model_path.is_file()
@cached_property
def model_cfg(self) -> dict[str, Any]:
log.debug(f"Loading model config for CLIP model '{self.model_name}'")
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
log.debug(f"Loaded model config for CLIP model '{self.model_name}'")
return model_cfg
@cached_property
def preprocess_cfg(self) -> dict[str, Any]:
log.debug(f"Loading visual preprocessing config for CLIP model '{self.model_name}'")
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open())
log.debug(f"Loaded visual preprocessing config for CLIP model '{self.model_name}'")
return preprocess_cfg
class OpenClipVisualEncoder(BaseCLIPVisualEncoder):
def _load(self) -> ModelSession:
size: list[int] | int = self.preprocess_cfg["size"]
self.size = size[0] if isinstance(size, list) else size
self.resampling = get_pil_resampling(self.preprocess_cfg["interpolation"])
self.mean = np.array(self.preprocess_cfg["mean"], dtype=np.float32)
self.std = np.array(self.preprocess_cfg["std"], dtype=np.float32)
return super()._load()
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:
image = resize_pil(image, self.size)
image = crop_pil(image, self.size)
image_np = to_numpy(image)
image_np = normalize(image_np, self.mean, self.std)
return {"image": np.expand_dims(image_np.transpose(2, 0, 1), 0)}
+10 -6
View File
@@ -1,4 +1,5 @@
from app.config import clean_name
from app.schemas import ModelSource
_OPENCLIP_MODELS = {
"RN50__openai",
@@ -54,13 +55,16 @@ _INSIGHTFACE_MODELS = {
SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"]
def is_openclip(model_name: str) -> bool:
return clean_name(model_name) in _OPENCLIP_MODELS
def get_model_source(model_name: str) -> ModelSource | None:
cleaned_name = clean_name(model_name)
if cleaned_name in _INSIGHTFACE_MODELS:
return ModelSource.INSIGHTFACE
def is_mclip(model_name: str) -> bool:
return clean_name(model_name) in _MCLIP_MODELS
if cleaned_name in _MCLIP_MODELS:
return ModelSource.MCLIP
if cleaned_name in _OPENCLIP_MODELS:
return ModelSource.OPENCLIP
def is_insightface(model_name: str) -> bool:
return clean_name(model_name) in _INSIGHTFACE_MODELS
return None
@@ -1,90 +0,0 @@
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from insightface.model_zoo import ArcFaceONNX, RetinaFace
from insightface.utils.face_align import norm_crop
from numpy.typing import NDArray
from app.config import clean_name
from app.schemas import Face, ModelType, is_ndarray
from .base import InferenceModel
class FaceRecognizer(InferenceModel):
_model_type = ModelType.FACIAL_RECOGNITION
def __init__(
self,
model_name: str,
min_score: float = 0.7,
cache_dir: Path | str | None = None,
**model_kwargs: Any,
) -> None:
self.min_score = model_kwargs.pop("minScore", min_score)
super().__init__(clean_name(model_name), cache_dir, **model_kwargs)
def _load(self) -> None:
self.det_model = RetinaFace(session=self._make_session(self.det_file))
self.rec_model = ArcFaceONNX(
self.rec_file.with_suffix(".onnx").as_posix(),
session=self._make_session(self.rec_file),
)
self.det_model.prepare(
ctx_id=0,
det_thresh=self.min_score,
input_size=(640, 640),
)
self.rec_model.prepare(ctx_id=0)
def _predict(self, image: NDArray[np.uint8] | bytes) -> list[Face]:
if isinstance(image, bytes):
decoded_image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR)
else:
decoded_image = image
assert is_ndarray(decoded_image, np.uint8)
bboxes, kpss = self.det_model.detect(decoded_image)
if bboxes.size == 0:
return []
assert is_ndarray(kpss, np.float32)
scores = bboxes[:, 4].tolist()
bboxes = bboxes[:, :4].round().tolist()
results = []
height, width, _ = decoded_image.shape
for (x1, y1, x2, y2), score, kps in zip(bboxes, scores, kpss):
cropped_img = norm_crop(decoded_image, kps)
embedding: NDArray[np.float32] = self.rec_model.get_feat(cropped_img)[0]
face: Face = {
"imageWidth": width,
"imageHeight": height,
"boundingBox": {
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
},
"score": score,
"embedding": embedding,
}
results.append(face)
return results
@property
def cached(self) -> bool:
return self.det_file.is_file() and self.rec_file.is_file()
@property
def det_file(self) -> Path:
return self.cache_dir / "detection" / f"model.{self.preferred_runtime}"
@property
def rec_file(self) -> Path:
return self.cache_dir / "recognition" / f"model.{self.preferred_runtime}"
def configure(self, **model_kwargs: Any) -> None:
self.det_model.det_thresh = model_kwargs.pop("minScore", self.det_model.det_thresh)
@@ -0,0 +1,60 @@
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from insightface.model_zoo import RetinaFace
from numpy.typing import NDArray
from app.schemas import DetectedFace, ModelSession, ModelTask, ModelType, is_ndarray
from app.models.base import InferenceModel
class FaceDetector(InferenceModel):
_model_task = ModelTask.FACIAL_RECOGNITION
_model_type = ModelType.DETECTION
def __init__(
self,
model_name: str,
min_score: float = 0.7,
cache_dir: Path | str | None = None,
**model_kwargs: Any,
) -> None:
self.min_score = model_kwargs.pop("minScore", min_score)
super().__init__(model_name, cache_dir, **model_kwargs)
def _load(self) -> ModelSession:
session = self._make_session(self.model_path)
self.det_model = RetinaFace(session=session)
self.det_model.prepare(ctx_id=0, det_thresh=self.min_score, input_size=(640, 640))
return session
def _predict(self, inputs: NDArray[np.uint8] | bytes, **kwargs: Any) -> list[DetectedFace]:
if isinstance(inputs, bytes):
decoded_image = cv2.imdecode(np.frombuffer(inputs, np.uint8), cv2.IMREAD_COLOR)
else:
decoded_image = inputs
assert is_ndarray(decoded_image, np.uint8)
bboxes, landmarks = self.det_model.detect(decoded_image)
assert is_ndarray(bboxes, np.float32)
assert is_ndarray(landmarks, np.float32)
if bboxes.size == 0:
return []
scores: list[float] = bboxes[:, 4].tolist()
bboxes_list: list[list[int]] = bboxes[:, :4].round().tolist()
results: list[DetectedFace] = [
{"box": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, "score": score, "landmarks": face_landmarks}
for (x1, y1, x2, y2), score, face_landmarks in zip(bboxes_list, scores, landmarks)
]
return results
def configure(self, **kwargs: Any) -> None:
self.det_model.det_thresh = kwargs.pop("minScore", self.det_model.det_thresh)
@@ -0,0 +1,31 @@
from typing import Any
import cv2
import numpy as np
from numpy.typing import NDArray
from app.models.facial_recognition.detection import FaceDetector
from app.models.facial_recognition.recognition import FaceRecognizer
from app.schemas import RecognizedFace, is_ndarray
class FacialRecognitionPipeline:
def __init__(self, det_model: FaceDetector, rec_model: FaceRecognizer) -> None:
self.det_model = det_model
self.rec_model = rec_model
self.loaded = False
def load(self) -> None:
self.det_model.load()
self.rec_model.load()
self.loaded = True
def predict(self, inputs: NDArray[np.uint8] | bytes, **kwargs: Any) -> list[RecognizedFace]:
if isinstance(inputs, bytes):
decoded_image = cv2.imdecode(np.frombuffer(inputs, np.uint8), cv2.IMREAD_COLOR)
else:
decoded_image = inputs
assert is_ndarray(decoded_image, np.uint8)
faces = self.det_model.predict(decoded_image, **kwargs)
results: list[RecognizedFace] = self.rec_model.predict(decoded_image, faces=faces, **kwargs)
return results
@@ -0,0 +1,65 @@
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from insightface.model_zoo import ArcFaceONNX
from insightface.utils.face_align import norm_crop
from numpy.typing import NDArray
from app.config import clean_name
from app.models.transforms import crop_np, crop_bounding_box, resize_np
from app.schemas import DetectedFace, ModelTask, RecognizedFace, ModelSession, ModelType, is_ndarray
from ..base import InferenceModel
class FaceRecognizer(InferenceModel):
_model_task = ModelTask.FACIAL_RECOGNITION
_model_type = ModelType.RECOGNITION
def __init__(
self,
model_name: str,
min_score: float = 0.7,
cache_dir: Path | str | None = None,
**model_kwargs: Any,
) -> None:
self.min_score = model_kwargs.pop("minScore", min_score)
super().__init__(clean_name(model_name), cache_dir, **model_kwargs)
def _load(self) -> ModelSession:
session = self._make_session(self.model_path)
self.model = ArcFaceONNX(
self.model_path.with_suffix(".onnx").as_posix(),
session=session,
)
return session
# def _predict(self, img: Any, **kwargs: Any) -> Any:
def _predict(
self, inputs: NDArray[np.uint8] | bytes, faces: list[DetectedFace] = [], **kwargs: Any
) -> list[RecognizedFace]:
if isinstance(inputs, bytes):
decoded_image = cv2.imdecode(np.frombuffer(inputs, np.uint8), cv2.IMREAD_COLOR)
else:
decoded_image = inputs
assert is_ndarray(decoded_image, np.float32)
results: list[RecognizedFace] = []
for detected_face in faces:
landmarks = detected_face.get("landmarks", None)
if landmarks is not None:
cropped_img = norm_crop(decoded_image, np.asarray(landmarks))
else:
cropped_img = crop_bounding_box(decoded_image, detected_face["box"])
cropped_img = crop_np(resize_np(cropped_img, 112), 112)
assert is_ndarray(cropped_img, np.uint8)
embedding = self.model.get_feat(cropped_img)[0]
assert is_ndarray(embedding, np.float32)
face: RecognizedFace = {"box": detected_face["box"], "embedding": embedding}
results.append(face)
return results
+47 -2
View File
@@ -1,19 +1,54 @@
import cv2
import numpy as np
from numpy.typing import NDArray
from PIL import Image
from app.schemas import BoundingBox, is_ndarray
_PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling}
def resize(img: Image.Image, size: int) -> Image.Image:
def resize_pil(img: Image.Image, size: int) -> Image.Image:
if img.width < img.height:
return img.resize((size, int((img.height / img.width) * size)), resample=Image.BICUBIC)
else:
return img.resize((int((img.width / img.height) * size), size), resample=Image.BICUBIC)
def resize_np(img: NDArray[np.float32], size: int) -> NDArray[np.float32]:
height, width = img.shape[:2]
if width < height:
res = cv2.resize(img, (size, int((height / width) * size)), interpolation=cv2.INTER_CUBIC)
else:
res = cv2.resize(img, (int((width / height) * size), size), interpolation=cv2.INTER_CUBIC)
assert is_ndarray(res, np.float32)
return res
# ported from server
def crop_bounding_box(image: NDArray[np.float32], bbox: BoundingBox, scale: float = 1.0) -> NDArray[np.float32]:
middle_x = (bbox["x1"] + bbox["x2"]) // 2
middle_y = (bbox["y1"] + bbox["y2"]) // 2
target_half_size = int(max((bbox["x2"] - bbox["x1"]) / 2, (bbox["y2"] - bbox["y1"]) / 2) * scale)
new_half_size = min(
middle_x - max(0, middle_x - target_half_size),
middle_y - max(0, middle_y - target_half_size),
min(image.shape[1] - 1, middle_x + target_half_size) - middle_x,
min(image.shape[0] - 1, middle_y + target_half_size) - middle_y,
)
left = middle_x - new_half_size
top = middle_y - new_half_size
width = int(new_half_size * 2)
height = int(new_half_size * 2)
return image[top : top + height, left : left + width]
# https://stackoverflow.com/a/60883103
def crop(img: Image.Image, size: int) -> Image.Image:
def crop_pil(img: Image.Image, size: int) -> Image.Image:
left = int((img.size[0] / 2) - (size / 2))
upper = int((img.size[1] / 2) - (size / 2))
right = left + size
@@ -22,6 +57,16 @@ def crop(img: Image.Image, size: int) -> Image.Image:
return img.crop((left, upper, right, lower))
def crop_np(img: NDArray[np.float32], size: int) -> NDArray[np.generic]:
height, width = img.shape[:2]
left = int((width / 2) - (size / 2))
upper = int((height / 2) - (size / 2))
right = left + size
lower = upper + size
return img[upper:lower, left:right]
def to_numpy(img: Image.Image) -> NDArray[np.float32]:
return np.asarray(img.convert("RGB")).astype(np.float32) / 255.0
+44 -10
View File
@@ -3,7 +3,7 @@ from typing import Any, Protocol, TypedDict, TypeGuard
import numpy as np
import numpy.typing as npt
from pydantic import BaseModel
from pydantic import BaseModel, Field
class StrEnum(str, Enum):
@@ -28,26 +28,60 @@ class BoundingBox(TypedDict):
y2: int
class ModelType(StrEnum):
CLIP = "clip"
class ModelTask(StrEnum):
FACIAL_RECOGNITION = "facial-recognition"
SEARCH = "clip"
class ModelRuntime(StrEnum):
ONNX = "onnx"
class ModelType(StrEnum):
DETECTION = "detection"
PIPELINE = "pipeline"
RECOGNITION = "recognition"
TEXTUAL = "textual"
VISUAL = "visual"
class ModelFormat(StrEnum):
ARMNN = "armnn"
ONNX = "onnx"
class ModelSource(StrEnum):
INSIGHTFACE = "insightface"
MCLIP = "mclip"
OPENCLIP = "openclip"
class ModelSession(Protocol):
def run(
self,
output_names: list[str] | None,
input_feed: dict[str, npt.NDArray[np.float32]] | dict[str, npt.NDArray[np.int32]],
run_options: Any = None,
) -> list[npt.NDArray[np.float32]]: ...
class Predictor(Protocol):
loaded: bool
def load(self) -> None: ...
def predict(self, inputs: Any, **model_kwargs: Any) -> Any: ...
class HasProfiling(Protocol):
profiling: dict[str, float]
class Face(TypedDict):
boundingBox: BoundingBox
embedding: npt.NDArray[np.float32]
imageWidth: int
imageHeight: int
class DetectedFace(TypedDict):
box: BoundingBox
score: float
landmarks: npt.NDArray[np.float32] | None
class RecognizedFace(TypedDict):
box: BoundingBox
embedding: npt.NDArray[np.float32]
def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
+87 -84
View File
@@ -17,13 +17,14 @@ from pytest import MonkeyPatch
from pytest_mock import MockerFixture
from app.main import load, preload_models
from app.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder
from app.models.clip.visual import OpenClipVisualEncoder
from app.models.facial_recognition.recognition import FaceRecognizer
from .config import Settings, log, settings
from .models.base import InferenceModel
from .models.cache import ModelCache
from .models.clip import MCLIPEncoder, OpenCLIPEncoder
from .models.facial_recognition import FaceRecognizer
from .schemas import ModelRuntime, ModelType
from .schemas import ModelFormat, ModelTask, ModelType
class TestBase:
@@ -35,13 +36,13 @@ class TestBase:
@pytest.mark.providers(CPU_EP)
def test_sets_cpu_provider(self, providers: list[str]) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.providers == self.CPU_EP
@pytest.mark.providers(CUDA_EP)
def test_sets_cuda_provider_if_available(self, providers: list[str]) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.providers == self.CUDA_EP
@@ -50,7 +51,7 @@ class TestBase:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.providers == self.OV_EP
@@ -59,25 +60,25 @@ class TestBase:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.providers == self.CPU_EP
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.providers == self.CUDA_EP
@pytest.mark.providers(TRT_EP)
def test_ignores_unsupported_providers(self, providers: list[str]) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.providers == self.CUDA_EP
def test_sets_provider_kwarg(self) -> None:
providers = ["CUDAExecutionProvider"]
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=providers)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", providers=providers)
assert encoder.providers == providers
@@ -85,7 +86,9 @@ class TestBase:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
encoder = OpenClipTextualEncoder(
"ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]
)
assert encoder.provider_options == [
{"device_type": "GPU_FP32", "cache_dir": (encoder.cache_dir / "openvino").as_posix()},
@@ -93,7 +96,7 @@ class TestBase:
]
def test_sets_provider_options_kwarg(self) -> None:
encoder = OpenCLIPEncoder(
encoder = OpenClipTextualEncoder(
"ViT-B-32__openai",
providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"],
provider_options=[],
@@ -102,7 +105,7 @@ class TestBase:
assert encoder.provider_options == []
def test_sets_default_sess_options(self) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
assert encoder.sess_options.inter_op_num_threads == 1
@@ -110,7 +113,9 @@ class TestBase:
assert encoder.sess_options.enable_cpu_mem_arena is False
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
encoder = OpenClipTextualEncoder(
"ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]
)
assert encoder.sess_options.inter_op_num_threads == 0
assert encoder.sess_options.intra_op_num_threads == 0
@@ -120,14 +125,16 @@ class TestBase:
mock_settings.model_inter_op_threads = 2
mock_settings.model_intra_op_threads = 4
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
encoder = OpenClipTextualEncoder(
"ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]
)
assert encoder.sess_options.inter_op_num_threads == 2
assert encoder.sess_options.intra_op_num_threads == 4
def test_sets_sess_options_kwarg(self) -> None:
sess_options = ort.SessionOptions()
encoder = OpenCLIPEncoder(
encoder = OpenClipTextualEncoder(
"ViT-B-32__openai",
providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"],
provider_options=[],
@@ -137,13 +144,13 @@ class TestBase:
assert sess_options is encoder.sess_options
def test_sets_default_cache_dir(self) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.cache_dir == Path(settings.cache_folder) / "clip" / "ViT-B-32__openai"
def test_sets_cache_dir_kwarg(self) -> None:
cache_dir = Path("/test_cache")
encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir=cache_dir)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=cache_dir)
assert encoder.cache_dir == cache_dir
@@ -151,29 +158,29 @@ class TestBase:
mocker.patch.object(settings, "ann", True)
mocker.patch("ann.ann.is_available", False)
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.preferred_runtime == ModelRuntime.ONNX
assert encoder.preferred_runtime == ModelFormat.ONNX
def test_sets_default_preferred_runtime_to_armnn_if_available(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "ann", True)
mocker.patch("ann.ann.is_available", True)
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
assert encoder.preferred_runtime == ModelRuntime.ARMNN
assert encoder.preferred_runtime == ModelFormat.ARMNN
def test_sets_preferred_runtime_kwarg(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "ann", False)
mocker.patch("ann.ann.is_available", False)
encoder = OpenCLIPEncoder("ViT-B-32__openai", preferred_runtime=ModelRuntime.ARMNN)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", preferred_runtime=ModelFormat.ARMNN)
assert encoder.preferred_runtime == ModelRuntime.ARMNN
assert encoder.preferred_runtime == ModelFormat.ARMNN
def test_casts_cache_dir_string_to_path(self) -> None:
cache_dir = "/test_cache"
encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir=cache_dir)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=cache_dir)
assert encoder.cache_dir == Path(cache_dir)
@@ -186,7 +193,7 @@ class TestBase:
mocker.patch("app.models.base.Path", return_value=mock_cache_dir)
info = mocker.spy(log, "info")
encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
encoder.clear_cache()
mock_rmtree.assert_called_once_with(encoder.cache_dir)
@@ -201,7 +208,7 @@ class TestBase:
mocker.patch("app.models.base.Path", return_value=mock_cache_dir)
warning = mocker.spy(log, "warning")
encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
encoder.clear_cache()
mock_rmtree.assert_not_called()
@@ -215,7 +222,7 @@ class TestBase:
mock_cache_dir.is_dir.return_value = True
mocker.patch("app.models.base.Path", return_value=mock_cache_dir)
encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
with pytest.raises(RuntimeError):
encoder.clear_cache()
@@ -230,7 +237,7 @@ class TestBase:
mocker.patch("app.models.base.Path", return_value=mock_cache_dir)
warning = mocker.spy(log, "warning")
encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=mock_cache_dir)
encoder.clear_cache()
mock_rmtree.assert_not_called()
@@ -245,7 +252,7 @@ class TestBase:
mock_model_path.with_suffix.return_value = mock_model_path
mock_ann = mocker.patch("app.models.base.AnnSession")
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
encoder._make_session(mock_model_path)
mock_ann.assert_called_once()
@@ -263,7 +270,7 @@ class TestBase:
mock_ann = mocker.patch("app.models.base.AnnSession")
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
encoder._make_session(mock_armnn_path)
mock_ort.assert_called_once()
@@ -277,7 +284,7 @@ class TestBase:
mock_ann = mocker.patch("app.models.base.AnnSession")
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
with pytest.raises(ValueError):
encoder._make_session(mock_model_path)
@@ -287,7 +294,7 @@ class TestBase:
def test_download(self, mocker: MockerFixture) -> None:
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")
encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir="/path/to/cache")
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="/path/to/cache")
encoder.download()
mock_snapshot_download.assert_called_once_with(
@@ -301,7 +308,7 @@ class TestBase:
def test_download_downloads_armnn_if_preferred_runtime(self, mocker: MockerFixture) -> None:
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")
encoder = OpenCLIPEncoder("ViT-B-32__openai", preferred_runtime=ModelRuntime.ARMNN)
encoder = OpenClipTextualEncoder("ViT-B-32__openai", preferred_runtime=ModelFormat.ARMNN)
encoder.download()
mock_snapshot_download.assert_called_once_with(
@@ -323,21 +330,18 @@ class TestCLIP:
mocker: MockerFixture,
clip_model_cfg: dict[str, Any],
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
) -> None:
mocker.patch.object(OpenCLIPEncoder, "download")
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
mocker.patch.object(OpenClipVisualEncoder, "download")
mocker.patch.object(OpenClipVisualEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenClipVisualEncoder, "preprocess_cfg", clip_preprocess_cfg)
mocked = mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
mocked.run.return_value = [[self.embedding]]
mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True)
clip_encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="vision")
clip_encoder = OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="vision")
embedding = clip_encoder.predict(pil_image)
assert clip_encoder.mode == "vision"
assert isinstance(embedding, np.ndarray)
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
assert embedding.dtype == np.float32
@@ -347,22 +351,19 @@ class TestCLIP:
self,
mocker: MockerFixture,
clip_model_cfg: dict[str, Any],
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
) -> None:
mocker.patch.object(OpenCLIPEncoder, "download")
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
mocker.patch.object(OpenClipTextualEncoder, "download")
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
mocked = mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
mocked.run.return_value = [[self.embedding]]
mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True)
clip_encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="text")
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="text")
embedding = clip_encoder.predict("test search query")
assert clip_encoder.mode == "text"
assert isinstance(embedding, np.ndarray)
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
assert embedding.dtype == np.float32
@@ -372,18 +373,16 @@ class TestCLIP:
self,
mocker: MockerFixture,
clip_model_cfg: dict[str, Any],
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
) -> None:
mocker.patch.object(OpenCLIPEncoder, "download")
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
mocker.patch.object(OpenClipTextualEncoder, "download")
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
mock_tokenizer = mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True).return_value
mock_ids = [randint(0, 50000) for _ in range(77)]
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids)
clip_encoder = OpenCLIPEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="text")
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="text")
clip_encoder._load_tokenizer()
tokens = clip_encoder.tokenize("test search query")
@@ -397,19 +396,17 @@ class TestCLIP:
self,
mocker: MockerFixture,
clip_model_cfg: dict[str, Any],
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
) -> None:
mocker.patch.object(OpenCLIPEncoder, "download")
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
mocker.patch.object(MClipTextualEncoder, "download")
mocker.patch.object(MClipTextualEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(MClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
mock_tokenizer = mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True).return_value
mock_ids = [randint(0, 50000) for _ in range(77)]
mock_attention_mask = [randint(0, 1) for _ in range(77)]
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids, attention_mask=mock_attention_mask)
clip_encoder = MCLIPEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="text")
clip_encoder = MClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache", mode="text")
clip_encoder._load_tokenizer()
tokens = clip_encoder.tokenize("test search query")
@@ -440,12 +437,12 @@ class TestFaceRecognition:
score = np.array([[0.67]] * num_faces).astype(np.float32)
kpss = np.random.rand(num_faces, 5, 2).astype(np.float32)
det_model.detect.return_value = (np.concatenate([bbox, score], axis=-1), kpss)
face_recognizer.det_model = det_model
face_recognizer.model = det_model
rec_model = mock.Mock()
embedding = np.random.rand(num_faces, 512).astype(np.float32)
rec_model.get_feat.return_value = embedding
face_recognizer.rec_model = rec_model
face_recognizer.model = rec_model
faces = face_recognizer.predict(cv_image)
@@ -465,24 +462,28 @@ class TestFaceRecognition:
class TestCache:
async def test_caches(self, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache()
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION)
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION)
assert len(model_cache.cache._cache) == 1
mock_get_model.assert_called_once()
async def test_kwargs_used(self, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache()
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, cache_dir="test_cache")
mock_get_model.assert_called_once_with(ModelType.FACIAL_RECOGNITION, "test_model_name", cache_dir="test_cache")
await model_cache.get(
"test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, cache_dir="test_cache"
)
mock_get_model.assert_called_once_with(
ModelTask.FACIAL_RECOGNITION, ModelType.RECOGNITION, "test_model_name", cache_dir="test_cache"
)
async def test_different_clip(self, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache()
await model_cache.get("test_image_model_name", ModelType.CLIP)
await model_cache.get("test_text_model_name", ModelType.CLIP)
await model_cache.get("test_image_model_name", ModelType.VISUAL, ModelTask.SEARCH)
await model_cache.get("test_text_model_name", ModelType.TEXTUAL, ModelTask.SEARCH)
mock_get_model.assert_has_calls(
[
mock.call(ModelType.CLIP, "test_image_model_name"),
mock.call(ModelType.CLIP, "test_text_model_name"),
mock.call(ModelTask.SEARCH, "test_image_model_name"),
mock.call(ModelTask.SEARCH, "test_text_model_name"),
]
)
assert len(model_cache.cache._cache) == 2
@@ -490,19 +491,19 @@ class TestCache:
@mock.patch("app.models.cache.OptimisticLock", autospec=True)
async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache()
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
@mock.patch("app.models.cache.SimpleMemoryCache.expire")
async def test_revalidate_get(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache(revalidate=True)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
mock_cache_expire.assert_called_once_with(mock.ANY, 100)
async def test_profiling(self, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache(profiling=True)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
profiling = await model_cache.get_profiling()
assert isinstance(profiling, dict)
assert profiling == model_cache.cache.profiling
@@ -510,9 +511,9 @@ class TestCache:
async def test_loads_mclip(self) -> None:
model_cache = ModelCache()
model = await model_cache.get("XLM-Roberta-Large-Vit-B-32", ModelType.CLIP, mode="text")
model = await model_cache.get("XLM-Roberta-Large-Vit-B-32", ModelType.TEXTUAL, ModelTask.SEARCH)
assert isinstance(model, MCLIPEncoder)
assert isinstance(model, MClipTextualEncoder)
assert model.model_name == "XLM-Roberta-Large-Vit-B-32"
async def test_raises_exception_if_invalid_model_type(self) -> None:
@@ -520,13 +521,13 @@ class TestCache:
model_cache = ModelCache()
with pytest.raises(ValueError):
await model_cache.get("XLM-Roberta-Large-Vit-B-32", invalid, mode="text")
await model_cache.get("XLM-Roberta-Large-Vit-B-32", ModelType.TEXTUAL, invalid)
async def test_raises_exception_if_unknown_model_name(self) -> None:
model_cache = ModelCache()
with pytest.raises(ValueError):
await model_cache.get("test_model_name", ModelType.CLIP, mode="text")
await model_cache.get("test_model_name", ModelType.TEXTUAL, ModelTask.SEARCH)
async def test_preloads_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
os.environ["MACHINE_LEARNING_PRELOAD__CLIP"] = "ViT-B-32__openai"
@@ -541,11 +542,12 @@ class TestCache:
monkeypatch.setattr("app.main.model_cache", model_cache)
await preload_models(settings.preload)
assert len(model_cache.cache._cache) == 2
assert mock_get_model.call_count == 2
await model_cache.get("ViT-B-32__openai", ModelType.CLIP, ttl=100)
await model_cache.get("buffalo_s", ModelType.FACIAL_RECOGNITION, ttl=100)
assert mock_get_model.call_count == 2
assert len(model_cache.cache._cache) == 3
assert mock_get_model.call_count == 3
await model_cache.get("ViT-B-32__openai", ModelType.TEXTUAL, ModelTask.SEARCH, ttl=100)
await model_cache.get("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH, ttl=100)
await model_cache.get("buffalo_s", ModelType.PIPELINE, ModelTask.FACIAL_RECOGNITION, ttl=100)
assert mock_get_model.call_count == 3
@pytest.mark.asyncio
@@ -572,7 +574,8 @@ class TestLoad:
async def test_load_clears_cache_and_retries_if_os_error(self) -> None:
mock_model = mock.Mock(spec=InferenceModel)
mock_model.model_name = "test_model_name"
mock_model.model_type = ModelType.CLIP
mock_model.model_type = ModelType.VISUAL
mock_model.model_task = ModelTask.SEARCH
mock_model.load.side_effect = [OSError, None]
mock_model.loaded = False
+1 -1
View File
@@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:881dbb68d115182b2c12e7e77dc54ea5005fd4e0123ca009d822adb5b0631785 as builder
FROM mambaorg/micromamba:bookworm-slim@sha256:3624db3aee11d2f3f00d25f691aaaf8834b8bc4ec1b340dcdb48ef37281ea604 as builder
ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \
+40 -39
View File
@@ -1274,13 +1274,13 @@ socks = ["socksio (==1.*)"]
[[package]]
name = "huggingface-hub"
version = "0.21.4"
version = "0.22.2"
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
optional = false
python-versions = ">=3.8.0"
files = [
{file = "huggingface_hub-0.21.4-py3-none-any.whl", hash = "sha256:df37c2c37fc6c82163cdd8a67ede261687d80d1e262526d6c0ce73b6b3630a7b"},
{file = "huggingface_hub-0.21.4.tar.gz", hash = "sha256:e1f4968c93726565a80edf6dc309763c7b546d0cfe79aa221206034d50155531"},
{file = "huggingface_hub-0.22.2-py3-none-any.whl", hash = "sha256:3429e25f38ccb834d310804a3b711e7e4953db5a9e420cc147a5e194ca90fd17"},
{file = "huggingface_hub-0.22.2.tar.gz", hash = "sha256:32e9a9a6843c92f253ff9ca16b9985def4d80a93fb357af5353f770ef74a81be"},
]
[package.dependencies]
@@ -1293,15 +1293,16 @@ tqdm = ">=4.42.1"
typing-extensions = ">=3.7.4.3"
[package.extras]
all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "mypy (==1.5.1)", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.1.3)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.3.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
cli = ["InquirerPy (==0.3.4)"]
dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "mypy (==1.5.1)", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.1.3)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.3.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"]
hf-transfer = ["hf-transfer (>=0.1.4)"]
inference = ["aiohttp", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)"]
quality = ["mypy (==1.5.1)", "ruff (>=0.1.3)"]
inference = ["aiohttp", "minijinja (>=1.0)"]
quality = ["mypy (==1.5.1)", "ruff (>=0.3.0)"]
tensorflow = ["graphviz", "pydot", "tensorflow"]
testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"]
tensorflow-testing = ["keras (<3.0)", "tensorflow"]
testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"]
torch = ["safetensors", "torch"]
typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"]
@@ -1567,13 +1568,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]]
name = "locust"
version = "2.24.0"
version = "2.24.1"
description = "Developer friendly load testing framework"
optional = false
python-versions = ">=3.8"
files = [
{file = "locust-2.24.0-py3-none-any.whl", hash = "sha256:1b6b878b4fd0108fec956120815e69775d2616c8f4d1e9f365c222a7a5c17d9a"},
{file = "locust-2.24.0.tar.gz", hash = "sha256:6cffa378d995244a7472af6be1d6139331f19aee44e907deee73e0281252804d"},
{file = "locust-2.24.1-py3-none-any.whl", hash = "sha256:7f6ed4dc289aad66c304582e6d25e4de5d7c3b175b580332442ab2be35b9d916"},
{file = "locust-2.24.1.tar.gz", hash = "sha256:094161d44d94839bf1120fd7898b7abb9c143833743ba7c096beb470a236b9a7"},
]
[package.dependencies]
@@ -2495,13 +2496,13 @@ testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygm
[[package]]
name = "pytest-asyncio"
version = "0.23.5.post1"
version = "0.23.6"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"},
{file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"},
{file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"},
{file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"},
]
[package.dependencies]
@@ -2531,17 +2532,17 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
[[package]]
name = "pytest-mock"
version = "3.12.0"
version = "3.14.0"
description = "Thin-wrapper around the mock package for easier use with pytest"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"},
{file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"},
{file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"},
{file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"},
]
[package.dependencies]
pytest = ">=5.0"
pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
@@ -2844,28 +2845,28 @@ files = [
[[package]]
name = "ruff"
version = "0.3.3"
version = "0.3.4"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:973a0e388b7bc2e9148c7f9be8b8c6ae7471b9be37e1cc732f8f44a6f6d7720d"},
{file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfa60d23269d6e2031129b053fdb4e5a7b0637fc6c9c0586737b962b2f834493"},
{file = "ruff-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eca7ff7a47043cf6ce5c7f45f603b09121a7cc047447744b029d1b719278eb5"},
{file = "ruff-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7d3f6762217c1da954de24b4a1a70515630d29f71e268ec5000afe81377642d"},
{file = "ruff-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c19e8598916d9c6f5a5437671f55ee93c212a2c4c569605dc3842b6820386"},
{file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a6cbf216b69c7090f0fe4669501a27326c34e119068c1494f35aaf4cc683778"},
{file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352e95ead6964974b234e16ba8a66dad102ec7bf8ac064a23f95371d8b198aab"},
{file = "ruff-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6ab88c81c4040a817aa432484e838aaddf8bfd7ca70e4e615482757acb64f8"},
{file = "ruff-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79bca3a03a759cc773fca69e0bdeac8abd1c13c31b798d5bb3c9da4a03144a9f"},
{file = "ruff-0.3.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2700a804d5336bcffe063fd789ca2c7b02b552d2e323a336700abb8ae9e6a3f8"},
{file = "ruff-0.3.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd66469f1a18fdb9d32e22b79f486223052ddf057dc56dea0caaf1a47bdfaf4e"},
{file = "ruff-0.3.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45817af234605525cdf6317005923bf532514e1ea3d9270acf61ca2440691376"},
{file = "ruff-0.3.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0da458989ce0159555ef224d5b7c24d3d2e4bf4c300b85467b08c3261c6bc6a8"},
{file = "ruff-0.3.3-py3-none-win32.whl", hash = "sha256:f2831ec6a580a97f1ea82ea1eda0401c3cdf512cf2045fa3c85e8ef109e87de0"},
{file = "ruff-0.3.3-py3-none-win_amd64.whl", hash = "sha256:be90bcae57c24d9f9d023b12d627e958eb55f595428bafcb7fec0791ad25ddfc"},
{file = "ruff-0.3.3-py3-none-win_arm64.whl", hash = "sha256:0171aab5fecdc54383993389710a3d1227f2da124d76a2784a7098e818f92d61"},
{file = "ruff-0.3.3.tar.gz", hash = "sha256:38671be06f57a2f8aba957d9f701ea889aa5736be806f18c0cd03d6ff0cbca8d"},
{file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"},
{file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"},
{file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"},
{file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"},
{file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"},
{file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"},
{file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"},
{file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"},
{file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"},
{file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"},
{file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"},
{file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"},
{file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"},
{file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"},
{file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"},
{file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"},
{file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"},
]
[[package]]
@@ -3289,13 +3290,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.28.0"
version = "0.29.0"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
files = [
{file = "uvicorn-0.28.0-py3-none-any.whl", hash = "sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1"},
{file = "uvicorn-0.28.0.tar.gz", hash = "sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067"},
{file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"},
{file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"},
]
[package.dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.99.0"
version = "1.100.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

@@ -1,70 +1,27 @@
<vector android:height="200dp"
android:viewportHeight="1300"
android:viewportWidth="1300"
android:width="200dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#4081ef"
android:fillType="nonZero"
android:pathData="M578,922.6c-2.2,-0.2 -5.5,-1 -9.7,-2.2c-52.4,-15.7 -99,-46.5 -133.8,-88.5c-8.8,-10.7 -17.2,-22.4 -19.4,-27.5c-8.1,-18.1 -6.3,-38.7 4.8,-55.4c5,-7.5 13.2,-15 20.5,-18.7c1.2,-0.6 54.1,-20 55.8,-20.4c0.5,-0.1 0.5,0.2 -0.3,2.1c-0.7,1.7 -1,3.1 -1.1,5.5l-0.1,3.2l2.8,5.8c8.7,17.9 19.2,32.7 33.2,46.4c6.3,6.2 7.8,7.6 13.8,12.3c22.7,18.1 52,30.7 79.9,34.3c2.5,0.3 5,0.8 5.7,1c2.8,0.9 7.7,-0.8 11,-3.7l1.8,-1.6l-0.2,4.8c-0.1,2.7 -0.6,15.4 -1,28.3c-0.6,20.3 -0.8,24 -1.5,27.5c-3.9,20.7 -18.6,37.5 -38.4,44.1c-4.6,1.5 -8,2.2 -13.1,2.7c-4.6,0.5 -5.9,0.4 -10.7,0Z" />
<path
android:fillColor="#31a452"
android:fillType="nonZero"
android:pathData="M707.3,922.4c-4,-0.4 -9.4,-1.6 -13.2,-2.9c-3.4,-1.2 -10,-4.4 -12.5,-6.1c-10.9,-7.4 -19,-17.9 -23.1,-30c-2.2,-6.7 -2.3,-7.5 -3.3,-36.9c-0.5,-14.9 -0.9,-27.9 -0.9,-28.9l-0,-1.9l2.3,1.8c2.6,2 6.6,3.4 8.5,3.1c0.6,-0.1 3,-0.5 5.3,-0.8c37.7,-5.3 71.2,-22.2 97.4,-49.1c12.2,-12.5 21.4,-25.5 29.9,-42.4l3.5,-7l0,-3.6c0,-3.1 -0.1,-3.8 -1,-5.7c-0.5,-1.2 -0.9,-2.1 -0.9,-2.2c0.2,-0.2 55.3,20.1 56.9,20.9c2.6,1.3 6.6,4.1 9.9,7c9.2,7.7 16.1,19.4 18.8,31.8c0.7,3.1 0.8,4.8 0.8,11.3c0,8.6 -0.5,11.7 -2.9,18.7c-1.7,5 -2.9,7.2 -7.1,13.1c-7.6,11 -15.3,20.5 -25.2,31.2c-32.8,35.4 -76.5,62.5 -123.4,76.3c-8,2.5 -12.4,3 -19.8,2.3Z" />
<path
android:fillColor="#de7fb3"
android:fillType="nonZero"
android:pathData="M623.1,811c-25.9,-4.2 -50.7,-14.9 -71.7,-31c-5.2,-4 -8.7,-7.1 -14.1,-12.4c-12.7,-12.5 -21.9,-24.9 -30.5,-41.4c-2.3,-4.4 -2.4,-4.7 -2.4,-7.1c0,-8.8 8.5,-15.2 16.9,-12.7c5.6,1.7 9.6,6.8 9.7,12.2c0,2.6 -0.8,4.6 -2.6,6.2c-1.2,1.1 -3.2,1.9 -4.6,1.9c-1.2,0 -3.3,-0.8 -4.3,-1.6c-2.1,-1.8 -2,-1 0.4,3.2c19.3,33.8 52.3,59.1 90,69.1c5.7,1.5 11.5,2.7 11.8,2.4c0.1,-0.1 -0.4,-0.8 -1.3,-1.6c-5.1,-4.5 -2.3,-11.7 5,-12.8c5.4,-0.8 11.4,2.7 13.9,8c0.8,1.7 1,2.5 1,5.3c0,2.8 -0.1,3.5 -1,5.3c-2,4.3 -6.8,7.9 -10.3,7.8c-0.9,-0.1 -3.6,-0.5 -5.9,-0.8Z" />
<path
android:fillColor="#4081ef"
android:fillType="nonZero"
android:pathData="M665.1,811.2c-3.4,-1.3 -6.4,-4.3 -7.8,-8.1c-1.1,-2.9 -0.9,-7.3 0.5,-10.2c2.6,-5.3 8.7,-8.5 14.4,-7.5c2.9,0.5 4.7,1.9 6,4.3c0.8,1.6 1,2.2 0.8,3.6c-0.3,2.2 -0.9,3.3 -2.7,4.8c-0.8,0.7 -1.4,1.4 -1.3,1.5c0.5,0.5 13.4,-2.7 21.3,-5.4c33.6,-11.3 62.5,-35.1 80.4,-66.1c2.5,-4.4 2.6,-5 0.5,-3.2c-2.8,2.4 -7,1.9 -9.6,-1c-4,-4.6 -0.7,-13.8 6.1,-16.9c2,-0.9 2.7,-1 5.5,-1c2.9,0 3.5,0.1 5.6,1.1c4.4,2.1 7.4,6.4 7.8,11c0.2,2.2 0.1,2.3 -2.2,6.9c-23,45.9 -67,78.1 -117.2,85.9c-5.5,0.9 -6.3,1 -8.1,0.3Z" />
<path
android:fillColor="#31a452"
android:fillType="nonZero"
android:pathData="M578.6,771.5c-4.7,-0.9 -8.7,-2.7 -12.9,-5.9c-10.8,-8.1 -13.5,-22.3 -6.6,-33.7c0.7,-1.2 1.1,-2.2 1,-2.4c-0.2,-0.2 -1.2,-0.6 -2.3,-1.1c-7.6,-3 -13,-10.6 -13.5,-19.1c-0.5,-7.4 3.1,-15 9,-19.4c1,-0.7 2.2,-1.5 2.6,-1.8c0.8,-0.4 68.9,-22.7 69.4,-22.7c0.2,0 0.7,0.7 1.2,1.5c0.5,0.8 1.6,2.3 2.4,3.3c1.2,1.4 1.5,1.9 1.2,2.3c-0.2,0.3 -6.9,9.5 -14.8,20.5c-15.9,21.9 -15.5,21.3 -13.4,23.4c1.3,1.3 2.9,1.4 4.4,0.3c0.6,-0.4 7.5,-9.7 15.5,-20.7c11.2,-15.4 14.6,-19.9 15,-19.7c0.9,0.4 5.5,1.9 6.6,2.1l1,0.2l-0,35.3c-0,39.7 -0,38.8 -2.5,44c-2.6,5.3 -7.2,9.3 -12.7,11.2c-3.7,1.3 -6.8,1.6 -10.2,1c-5.5,-0.9 -9.8,-3.2 -13.7,-7.4l-2.2,-2.4l-0.6,0.9c-3,4.3 -8.6,8.1 -14,9.5c-2.8,0.9 -7.8,1.2 -9.9,0.8Z" />
<path
android:fillColor="#ffb800"
android:fillType="nonZero"
android:pathData="M710.4,771.5c-5.5,-0.9 -9.9,-3.2 -14.3,-7.6l-3.2,-3.2l-0.7,1c-2.3,3.3 -6.8,6.5 -11.1,7.9c-3.7,1.2 -9.2,1.4 -12.6,0.3c-7.1,-2.1 -12.7,-7.4 -15.2,-14.3l-0.9,-2.6l0,-74.2l1.8,-0.4c1,-0.2 2.7,-0.8 3.9,-1.2c1.1,-0.5 2.1,-0.8 2.2,-0.7c0.1,0.1 6.5,9 14.4,19.9c7.8,10.9 14.7,20.1 15.2,20.5c2.2,1.9 5.4,0.4 5.4,-2.6c0,-1.4 -1,-2.9 -13.8,-20.5c-7.6,-10.5 -14.2,-19.6 -14.7,-20.4l-0.9,-1.3l1.4,-1.7c0.8,-0.9 1.9,-2.5 2.5,-3.4l1,-1.6l34.4,11.2c18.9,6.2 35.1,11.6 35.9,12.1c6.8,4 11.1,11.3 11.1,19.1c0,4.1 -0.5,6.4 -2.4,10.2c-2,4.1 -5.5,7.6 -9.6,9.7c-1.6,0.8 -3.2,1.5 -3.4,1.5c-1,0 -0.9,0.7 0.3,2.6c2.8,4.3 4,8.5 3.9,13.7c0,8.1 -3.7,15.2 -10.6,20.3c-6.5,4.8 -13.4,6.7 -20,5.7Z" />
<path
android:fillColor="#de7fb3"
android:fillType="nonZero"
android:pathData="M421.4,714.9c-0.5,-0.1 -2.3,-0.4 -3.9,-0.7c-15.6,-2.6 -30.4,-12.6 -38.8,-26.2c-3.5,-5.7 -6.4,-13.2 -7.8,-19.9c-1.2,-6.1 -0.8,-28.1 0.8,-43.1c4.5,-43 19,-84.3 42.2,-120.7c6.5,-10.2 14.9,-21.5 18.2,-24.6c17.8,-16.6 43.1,-20.5 64.8,-10c4.3,2.1 8.8,5.1 12.7,8.6c2.8,2.4 5.8,6.1 20.9,25.5c9.7,12.5 17.8,22.8 17.9,23c0.2,0.2 -0.9,0.4 -3.2,0.4c-2.5,0 -4.1,0.2 -5.7,0.7c-2.1,0.7 -2.6,1.1 -7.9,6.3c-8.2,8.1 -14.4,15.3 -20.3,23.9c-15.5,22.2 -25.4,47.7 -28.8,74.8c-2.2,16.9 -1.6,37.5 1.6,52.3c0.3,1.4 0.5,2.8 0.4,3c-0.1,0.2 0.2,1.3 0.8,2.4c1.1,2.4 4.3,5.7 6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2 -13.1,3.8 -27.6,8c-16.4,4.7 -27.7,7.8 -29.8,8.1c-3.1,0.4 -11.1,0.6 -13.3,0.2Z" />
<path
android:fillColor="#ffb800"
android:fillType="nonZero"
android:pathData="M862.2,714.7c-2.1,-0.3 -33.8,-9.1 -56.5,-15.8l-2.5,-0.7l1.6,-0.8c3.4,-1.7 7.2,-6.6 7.3,-9.6c0,-0.7 0.4,-3.3 0.8,-5.8c3.9,-22.7 3.1,-46.1 -2.5,-68.4c-6.4,-25.5 -18.6,-49.2 -35.8,-69.1c-4.6,-5.3 -14.8,-15.4 -16.4,-16.1c-2.4,-1.1 -5.1,-1.6 -8,-1.4l-2.7,0.2l1.2,-1.5c0.7,-0.8 8.5,-10.8 17.5,-22.3c8.9,-11.5 17.2,-21.8 18.5,-23.1c2.6,-2.7 7,-6.2 10.3,-8.2c19.3,-11.6 43,-11.1 61.6,1.2c5.4,3.6 8.2,6.2 12.3,11.7c26.4,34.5 44,73.7 52.3,116.2c3.4,17.6 4.9,33.3 5,52.4c0,13 -0.2,14.8 -2.5,21.8c-8.4,26.2 -34.5,42.8 -61.5,39.3Z" />
<path
android:fillColor="#e64132"
android:fillType="nonZero"
android:pathData="M501.4,691.5c-2,-0.5 -4.6,-1.9 -6,-3.3c-2.5,-2.4 -3.1,-3.5 -3.7,-7.3c-4.4,-27.3 -2.2,-54 6.7,-79.3c5.3,-15.1 13.5,-30.5 23,-43.1c5.8,-7.8 16.6,-19.5 19,-20.7c4.7,-2.4 11.3,-1.2 15.2,2.7c5.4,5.4 5.2,13.9 -0.3,19.1c-4.3,4 -9.4,4.4 -12.6,0.9c-1.7,-1.9 -2.2,-3.9 -1.7,-6.4c0.2,-1.1 0.3,-2 0.2,-2.2c-0.3,-0.3 -3.6,3.3 -8.3,9.1c-17.6,21.8 -28.5,48 -31.9,76.5c-1.1,9.3 -1,26.4 0.1,34.6c0.3,1.8 0.8,1.9 1.4,0.1c0.9,-2.6 4,-4.7 6.8,-4.7c3,-0 5.9,2.2 7.5,5.7c0.6,1.3 0.8,2.3 0.8,5.2c-0,3.3 -0.1,3.8 -1.1,5.7c-1.4,2.7 -4.6,5.7 -7.1,6.6c-2.5,0.9 -6.1,1.2 -8,0.8Z" />
<path
android:fillColor="#31a452"
android:fillType="nonZero"
android:pathData="M790.1,691.5c-3.7,-0.6 -7.7,-3.6 -9.4,-7.1c-3.8,-7.5 0.1,-16.9 6.9,-16.9c3.1,0 5.8,2 6.9,5.2c0.4,1.2 0.5,1.3 0.7,0.7c1.3,-3.7 1.7,-26.4 0.6,-35.7c-3.6,-29.6 -14.5,-55.3 -33,-77.9c-5.5,-6.7 -8.4,-9.4 -7.1,-6.6c0.7,1.4 0.5,4.3 -0.3,5.9c-0.9,1.7 -3.2,3.5 -5,3.8c-3.2,0.6 -7.9,-1.6 -10.2,-4.8c-6.5,-8.8 -0.5,-21.2 10.4,-21.4c4.6,-0.1 5.2,0.3 11.2,6.4c12.1,12.3 21.1,24.9 28.8,40.3c13.2,26.3 18.6,54.9 16.1,84.5c-0.5,5.6 -2,15.7 -2.6,17.1c-1.3,2.8 -4.8,5.5 -8.4,6.5c-2.3,0.4 -3.1,0.4 -5.6,0Z" />
<path
android:fillColor="#4081ef"
android:fillType="nonZero"
android:pathData="M545.7,680.2c-6,-1.3 -12.2,-6.2 -14.9,-11.7c-3.4,-7 -3.1,-15.1 0.9,-21.6c0.7,-1.2 1.2,-2.3 1.1,-2.4c-0.1,-0.1 -1.1,-0.6 -2.1,-1c-3.9,-1.5 -8.1,-4.8 -10.7,-8.3c-4.6,-6.2 -6.1,-14.6 -3.9,-22.1c2.9,-10.3 9.4,-16.8 19.1,-19.3c2.8,-0.7 9,-0.8 11.7,-0c1.1,0.3 2.2,0.5 2.4,0.5c0.2,-0 0.3,-0.7 0.3,-1.5c0,-2.9 0.8,-5.8 2.4,-9.2c5.2,-10.8 18.1,-15.5 29,-10.5c2.7,1.2 6.2,3.8 7.8,5.8c0.7,0.8 10.3,14 21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1 -1.9,2.6 -2.5,3.5c-0.6,1 -1.2,1.7 -1.5,1.6c-4.5,-1.7 -46.7,-15 -47.7,-15c-1.9,0 -3.1,1.3 -3.1,3.2c0,1 0.2,1.7 0.8,2.3c0.6,0.6 7.8,3.1 24.5,8.5l23.7,7.7l-0.2,8.6l-32.6,10.5c-18,5.9 -33.9,10.9 -35.2,11.2c-3.1,0.7 -6.6,0.7 -9.6,0.1Z" />
<path
android:fillColor="#e64132"
android:fillType="nonZero"
android:pathData="M740,679.8c-1.8,-0.5 -17.5,-5.6 -35,-11.3l-31.8,-10.4l1,-4.3l0,-4.3l22.6,-7.7c15,-4.9 24,-8 24.6,-8.5c0.7,-0.6 0.9,-1.1 0.9,-2.2c-0,-2 -1.2,-3.3 -3.1,-3.3c-0.9,-0 -10.5,2.9 -24.7,7.5c-12.8,4.1 -23.4,7.5 -23.6,7.5c-0.1,-0 -0.7,-0.8 -1.3,-1.9c-0.6,-1 -1.6,-2.5 -2.2,-3.2c-0.7,-0.7 -1.2,-1.5 -1.2,-1.6c0,-0.2 9.6,-13.5 21.4,-29.6c18.9,-26 21.6,-29.6 23.6,-31.1c5.7,-4.4 13.1,-5.8 19.7,-3.9c9,2.7 16.1,11.6 16.1,20.3c0,2.3 -0.1,2.3 3.1,1.5c4.7,-1.1 11.5,-0.5 16,1.5c4.6,2 9,6 11.5,10.2c2.1,3.6 3.9,9.4 4.2,13.2c0.3,5.2 -1.1,10.7 -4,15.3c-2.6,4.1 -7.8,8.3 -12.1,9.8c-0.9,0.3 -1.7,0.8 -1.7,1c0,0.2 0.4,1 0.9,1.7c2.4,3.6 3.6,7.7 3.5,12.7c0,5.8 -2.1,10.7 -6.4,15.1c-4,4.1 -8.9,6.3 -14.9,6.5c-3.3,0.4 -4.3,0.3 -7.1,-0.5Z" />
<path
android:fillColor="#f2f5fb"
android:fillType="nonZero"
android:pathData="M643.7,671.9c-6.1,-1.6 -11.4,-6.8 -13.2,-12.9c-0.7,-2.4 -0.7,-7.5 0,-9.9c1.7,-5.8 6.6,-10.8 12.3,-12.5c2.7,-0.8 7.2,-0.9 10,-0.2c6.2,1.6 11.6,7.1 13.2,13.3c1.6,6 -0.3,12.6 -5,17.3c-4.6,4.6 -11.3,6.5 -17.3,4.9Z" />
<path
android:fillColor="#de7fb3"
android:fillType="nonZero"
android:pathData="M615.8,602.8c-13.3,-18.3 -21.2,-29.6 -22,-31.1c-1.4,-3 -1.9,-5.5 -1.9,-9.4c0,-14.1 13.1,-24.4 27.1,-21.4c1.4,0.3 2.6,0.5 2.7,0.5c0.1,0 0.3,-1.3 0.4,-2.8c0.8,-10.7 8.4,-19.6 18.9,-22.4c3.9,-1 10.6,-1 14.5,-0c8.9,2.3 15.9,9.3 18.2,18.2c0.4,1.5 0.7,3.7 0.7,4.9c-0,1.2 0.1,2.1 0.3,2.1c0.2,-0 1.5,-0.3 3,-0.6c7.4,-1.6 15.2,0.7 20.5,6c4.3,4.3 6.6,9.6 6.6,15.6c-0,4 -0.6,6.5 -2.4,10c-0.6,1.2 -10.4,15 -21.7,30.7c-17.8,24.5 -20.8,28.5 -21.4,28.3c-0.4,-0.1 -1.9,-0.6 -3.4,-1.1c-1.5,-0.5 -2.9,-0.9 -3.3,-0.9c-0.7,-0 -0.7,-0.8 -0.3,-25.5l-0,-25.5l-1.4,-0.9c-1,-1.1 -2.5,-1.5 -3.8,-0.9c-2,0.8 -2,-0.5 -1.8,27.2l-0,25.8l-1.2,-0c-0.5,-0.2 -2.4,0.3 -4,0.9c-1.6,0.6 -3.1,1.1 -3.2,1.1c-0.2,-0.1 -9.6,-13 -21.1,-28.8Z" />
<path
android:fillColor="#ffb800"
android:fillType="nonZero"
android:pathData="M578.4,537.8c-4.1,-0.9 -7.7,-3.6 -9.6,-7.4c-1.4,-2.8 -1.7,-7.3 -0.5,-10.3c1.7,-4.5 3.9,-6.1 15.6,-11.2c15.8,-7 31.4,-11.1 49.2,-12.9c7.3,-0.8 23.2,-0.8 30.6,0c17.4,1.8 33.3,6 49.1,13c7.3,3.2 12.5,6.1 13.6,7.5c4.3,5.6 3.8,12.7 -1.1,17.6c-5.1,5.1 -12.9,5.4 -18.1,0.7c-2,-1.8 -3,-3.5 -3.4,-5.6c-0.7,-4 2.9,-8.1 7.3,-8.2c1.4,0 1.5,-0.1 1.1,-0.5c-0.3,-0.3 -2.2,-1.2 -4.3,-2.1c-33.2,-14.5 -70.5,-16.4 -105,-5.4c-7.5,2.4 -19,7.2 -18.6,7.7c0.1,0.2 0.8,0.3 1.6,0.3c5.6,0 9.1,6.2 6.1,10.8c-2.9,4.5 -8.6,7.1 -13.6,6Z" />
<path
android:fillColor="#e64132"
android:fillType="nonZero"
android:pathData="M542.2,496.4c-8.9,-13.1 -16.8,-25.1 -17.5,-26.6c-1.6,-3.3 -3.6,-9.2 -4.4,-13c-2.6,-12.5 -0.9,-25.8 5,-37.5c4.2,-8.3 11.2,-16.3 18.6,-21.3c5,-3.4 6.1,-3.9 12.8,-6.3c23.1,-8.2 47.2,-13.1 73.4,-15c7.5,-0.6 28.5,-0.6 36.3,-0c25.5,1.8 50.6,6.9 73,14.8c6.4,2.2 8.2,3.1 13.1,6.5c9.8,6.6 18.1,17.5 22,29.2c2.2,6.5 2.7,10 2.7,17.9c0,7.9 -0.5,11.3 -2.7,17.9c-2.3,6.8 -3.7,9.1 -20.3,33.6l-16.1,23.8l-0.4,-2.2c-0.2,-1.2 -0.9,-3 -1.4,-4c-1,-1.8 -4.4,-5.6 -4.7,-5.2c-0.1,0.1 -1.2,-0.4 -2.4,-1.1c-9.1,-5.2 -21.9,-10.5 -33.2,-13.9c-37,-11 -77.2,-8.8 -113,6.1c-4.9,2.1 -17.7,8.4 -19.2,9.5c-2.2,1.6 -5.1,6.8 -5.1,9c0,0.4 -0.1,1 -0.3,1.2c0.1,0.2 -6.2,-8.8 -16.2,-23.4Z" />
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FA2921"
android:fillType="nonZero"
android:pathData="m52.25,43.55c3.4,3.08 6.15,6.39 7.91,9.5 3.03,-5.55 5.06,-12.14 5.08,-16.34 0,-0.03 0,-0.06 0,-0.08 0,-6.21 -6.06,-8.63 -11.28,-8.63 -5.22,0 -11.28,2.42 -11.28,8.63 0,0.08 0,0.2 0,0.34 2.91,1.32 6.36,3.69 9.56,6.59z" />
<path
android:fillColor="#ED79B5"
android:fillType="nonZero"
android:pathData="m33.68,60.5c2.13,-2.42 5.39,-5.05 9.08,-7.27 3.92,-2.36 7.84,-4.01 11.28,-4.76 -4.22,-4.67 -9.72,-8.67 -13.62,-10 -0.03,-0.01 -0.05,-0.02 -0.08,-0.03 -5.78,-1.92 -9.9,3.23 -11.51,8.31 -1.61,5.08 -1.24,11.72 4.54,13.64 0.08,0.03 0.18,0.06 0.31,0.1z" />
<path
android:fillColor="#FFB400"
android:fillType="nonZero"
android:pathData="M79.17,46.67C77.56,41.59 73.44,36.44 67.66,38.36c-0.08,0.03 -0.19,0.06 -0.31,0.1 -0.33,3.24 -1.46,7.33 -3.17,11.34 -1.81,4.27 -4.04,7.96 -6.39,10.64 6.1,1.24 12.85,1.17 16.76,-0.1 0.03,-0.01 0.05,-0.02 0.08,-0.03 5.78,-1.92 6.15,-8.56 4.54,-13.64z" />
<path
android:fillColor="#1E83F7"
android:fillType="nonZero"
android:pathData="m48.82,65.46c-0.98,-4.54 -1.3,-8.87 -0.94,-12.45 -5.64,2.67 -11.07,6.78 -13.5,10.16 -0.02,0.02 -0.03,0.05 -0.05,0.07 -3.57,5.03 -0.06,10.63 4.16,13.77 4.22,3.14 10.51,4.83 14.09,-0.2 0.05,-0.07 0.11,-0.16 0.19,-0.27 -1.59,-2.82 -3.03,-6.81 -3.95,-11.08z" />
<path
android:fillColor="#18C249"
android:fillType="nonZero"
android:pathData="m73.58,62.92c-3.11,0.68 -7.26,0.84 -11.52,0.42 -4.53,-0.45 -8.64,-1.47 -11.86,-2.93 0.73,6.31 2.88,12.87 5.28,16.28 0.02,0.02 0.03,0.05 0.05,0.07 3.57,5.03 9.86,3.34 14.09,0.2 4.22,-3.14 7.74,-8.74 4.16,-13.77 -0.05,-0.07 -0.11,-0.16 -0.19,-0.27z" />
</vector>
@@ -1,70 +1,27 @@
<vector android:height="200dp"
android:viewportHeight="1300"
android:viewportWidth="1300"
android:width="200dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M578,922.6c-2.2,-0.2 -5.5,-1 -9.7,-2.2c-52.4,-15.7 -99,-46.5 -133.8,-88.5c-8.8,-10.7 -17.2,-22.4 -19.4,-27.5c-8.1,-18.1 -6.3,-38.7 4.8,-55.4c5,-7.5 13.2,-15 20.5,-18.7c1.2,-0.6 54.1,-20 55.8,-20.4c0.5,-0.1 0.5,0.2 -0.3,2.1c-0.7,1.7 -1,3.1 -1.1,5.5l-0.1,3.2l2.8,5.8c8.7,17.9 19.2,32.7 33.2,46.4c6.3,6.2 7.8,7.6 13.8,12.3c22.7,18.1 52,30.7 79.9,34.3c2.5,0.3 5,0.8 5.7,1c2.8,0.9 7.7,-0.8 11,-3.7l1.8,-1.6l-0.2,4.8c-0.1,2.7 -0.6,15.4 -1,28.3c-0.6,20.3 -0.8,24 -1.5,27.5c-3.9,20.7 -18.6,37.5 -38.4,44.1c-4.6,1.5 -8,2.2 -13.1,2.7c-4.6,0.5 -5.9,0.4 -10.7,0Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M707.3,922.4c-4,-0.4 -9.4,-1.6 -13.2,-2.9c-3.4,-1.2 -10,-4.4 -12.5,-6.1c-10.9,-7.4 -19,-17.9 -23.1,-30c-2.2,-6.7 -2.3,-7.5 -3.3,-36.9c-0.5,-14.9 -0.9,-27.9 -0.9,-28.9l-0,-1.9l2.3,1.8c2.6,2 6.6,3.4 8.5,3.1c0.6,-0.1 3,-0.5 5.3,-0.8c37.7,-5.3 71.2,-22.2 97.4,-49.1c12.2,-12.5 21.4,-25.5 29.9,-42.4l3.5,-7l0,-3.6c0,-3.1 -0.1,-3.8 -1,-5.7c-0.5,-1.2 -0.9,-2.1 -0.9,-2.2c0.2,-0.2 55.3,20.1 56.9,20.9c2.6,1.3 6.6,4.1 9.9,7c9.2,7.7 16.1,19.4 18.8,31.8c0.7,3.1 0.8,4.8 0.8,11.3c0,8.6 -0.5,11.7 -2.9,18.7c-1.7,5 -2.9,7.2 -7.1,13.1c-7.6,11 -15.3,20.5 -25.2,31.2c-32.8,35.4 -76.5,62.5 -123.4,76.3c-8,2.5 -12.4,3 -19.8,2.3Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M623.1,811c-25.9,-4.2 -50.7,-14.9 -71.7,-31c-5.2,-4 -8.7,-7.1 -14.1,-12.4c-12.7,-12.5 -21.9,-24.9 -30.5,-41.4c-2.3,-4.4 -2.4,-4.7 -2.4,-7.1c0,-8.8 8.5,-15.2 16.9,-12.7c5.6,1.7 9.6,6.8 9.7,12.2c0,2.6 -0.8,4.6 -2.6,6.2c-1.2,1.1 -3.2,1.9 -4.6,1.9c-1.2,0 -3.3,-0.8 -4.3,-1.6c-2.1,-1.8 -2,-1 0.4,3.2c19.3,33.8 52.3,59.1 90,69.1c5.7,1.5 11.5,2.7 11.8,2.4c0.1,-0.1 -0.4,-0.8 -1.3,-1.6c-5.1,-4.5 -2.3,-11.7 5,-12.8c5.4,-0.8 11.4,2.7 13.9,8c0.8,1.7 1,2.5 1,5.3c0,2.8 -0.1,3.5 -1,5.3c-2,4.3 -6.8,7.9 -10.3,7.8c-0.9,-0.1 -3.6,-0.5 -5.9,-0.8Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M665.1,811.2c-3.4,-1.3 -6.4,-4.3 -7.8,-8.1c-1.1,-2.9 -0.9,-7.3 0.5,-10.2c2.6,-5.3 8.7,-8.5 14.4,-7.5c2.9,0.5 4.7,1.9 6,4.3c0.8,1.6 1,2.2 0.8,3.6c-0.3,2.2 -0.9,3.3 -2.7,4.8c-0.8,0.7 -1.4,1.4 -1.3,1.5c0.5,0.5 13.4,-2.7 21.3,-5.4c33.6,-11.3 62.5,-35.1 80.4,-66.1c2.5,-4.4 2.6,-5 0.5,-3.2c-2.8,2.4 -7,1.9 -9.6,-1c-4,-4.6 -0.7,-13.8 6.1,-16.9c2,-0.9 2.7,-1 5.5,-1c2.9,0 3.5,0.1 5.6,1.1c4.4,2.1 7.4,6.4 7.8,11c0.2,2.2 0.1,2.3 -2.2,6.9c-23,45.9 -67,78.1 -117.2,85.9c-5.5,0.9 -6.3,1 -8.1,0.3Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M578.6,771.5c-4.7,-0.9 -8.7,-2.7 -12.9,-5.9c-10.8,-8.1 -13.5,-22.3 -6.6,-33.7c0.7,-1.2 1.1,-2.2 1,-2.4c-0.2,-0.2 -1.2,-0.6 -2.3,-1.1c-7.6,-3 -13,-10.6 -13.5,-19.1c-0.5,-7.4 3.1,-15 9,-19.4c1,-0.7 2.2,-1.5 2.6,-1.8c0.8,-0.4 68.9,-22.7 69.4,-22.7c0.2,0 0.7,0.7 1.2,1.5c0.5,0.8 1.6,2.3 2.4,3.3c1.2,1.4 1.5,1.9 1.2,2.3c-0.2,0.3 -6.9,9.5 -14.8,20.5c-15.9,21.9 -15.5,21.3 -13.4,23.4c1.3,1.3 2.9,1.4 4.4,0.3c0.6,-0.4 7.5,-9.7 15.5,-20.7c11.2,-15.4 14.6,-19.9 15,-19.7c0.9,0.4 5.5,1.9 6.6,2.1l1,0.2l-0,35.3c-0,39.7 -0,38.8 -2.5,44c-2.6,5.3 -7.2,9.3 -12.7,11.2c-3.7,1.3 -6.8,1.6 -10.2,1c-5.5,-0.9 -9.8,-3.2 -13.7,-7.4l-2.2,-2.4l-0.6,0.9c-3,4.3 -8.6,8.1 -14,9.5c-2.8,0.9 -7.8,1.2 -9.9,0.8Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M710.4,771.5c-5.5,-0.9 -9.9,-3.2 -14.3,-7.6l-3.2,-3.2l-0.7,1c-2.3,3.3 -6.8,6.5 -11.1,7.9c-3.7,1.2 -9.2,1.4 -12.6,0.3c-7.1,-2.1 -12.7,-7.4 -15.2,-14.3l-0.9,-2.6l0,-74.2l1.8,-0.4c1,-0.2 2.7,-0.8 3.9,-1.2c1.1,-0.5 2.1,-0.8 2.2,-0.7c0.1,0.1 6.5,9 14.4,19.9c7.8,10.9 14.7,20.1 15.2,20.5c2.2,1.9 5.4,0.4 5.4,-2.6c0,-1.4 -1,-2.9 -13.8,-20.5c-7.6,-10.5 -14.2,-19.6 -14.7,-20.4l-0.9,-1.3l1.4,-1.7c0.8,-0.9 1.9,-2.5 2.5,-3.4l1,-1.6l34.4,11.2c18.9,6.2 35.1,11.6 35.9,12.1c6.8,4 11.1,11.3 11.1,19.1c0,4.1 -0.5,6.4 -2.4,10.2c-2,4.1 -5.5,7.6 -9.6,9.7c-1.6,0.8 -3.2,1.5 -3.4,1.5c-1,0 -0.9,0.7 0.3,2.6c2.8,4.3 4,8.5 3.9,13.7c0,8.1 -3.7,15.2 -10.6,20.3c-6.5,4.8 -13.4,6.7 -20,5.7Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M421.4,714.9c-0.5,-0.1 -2.3,-0.4 -3.9,-0.7c-15.6,-2.6 -30.4,-12.6 -38.8,-26.2c-3.5,-5.7 -6.4,-13.2 -7.8,-19.9c-1.2,-6.1 -0.8,-28.1 0.8,-43.1c4.5,-43 19,-84.3 42.2,-120.7c6.5,-10.2 14.9,-21.5 18.2,-24.6c17.8,-16.6 43.1,-20.5 64.8,-10c4.3,2.1 8.8,5.1 12.7,8.6c2.8,2.4 5.8,6.1 20.9,25.5c9.7,12.5 17.8,22.8 17.9,23c0.2,0.2 -0.9,0.4 -3.2,0.4c-2.5,0 -4.1,0.2 -5.7,0.7c-2.1,0.7 -2.6,1.1 -7.9,6.3c-8.2,8.1 -14.4,15.3 -20.3,23.9c-15.5,22.2 -25.4,47.7 -28.8,74.8c-2.2,16.9 -1.6,37.5 1.6,52.3c0.3,1.4 0.5,2.8 0.4,3c-0.1,0.2 0.2,1.3 0.8,2.4c1.1,2.4 4.3,5.7 6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2 -13.1,3.8 -27.6,8c-16.4,4.7 -27.7,7.8 -29.8,8.1c-3.1,0.4 -11.1,0.6 -13.3,0.2Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M862.2,714.7c-2.1,-0.3 -33.8,-9.1 -56.5,-15.8l-2.5,-0.7l1.6,-0.8c3.4,-1.7 7.2,-6.6 7.3,-9.6c0,-0.7 0.4,-3.3 0.8,-5.8c3.9,-22.7 3.1,-46.1 -2.5,-68.4c-6.4,-25.5 -18.6,-49.2 -35.8,-69.1c-4.6,-5.3 -14.8,-15.4 -16.4,-16.1c-2.4,-1.1 -5.1,-1.6 -8,-1.4l-2.7,0.2l1.2,-1.5c0.7,-0.8 8.5,-10.8 17.5,-22.3c8.9,-11.5 17.2,-21.8 18.5,-23.1c2.6,-2.7 7,-6.2 10.3,-8.2c19.3,-11.6 43,-11.1 61.6,1.2c5.4,3.6 8.2,6.2 12.3,11.7c26.4,34.5 44,73.7 52.3,116.2c3.4,17.6 4.9,33.3 5,52.4c0,13 -0.2,14.8 -2.5,21.8c-8.4,26.2 -34.5,42.8 -61.5,39.3Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M501.4,691.5c-2,-0.5 -4.6,-1.9 -6,-3.3c-2.5,-2.4 -3.1,-3.5 -3.7,-7.3c-4.4,-27.3 -2.2,-54 6.7,-79.3c5.3,-15.1 13.5,-30.5 23,-43.1c5.8,-7.8 16.6,-19.5 19,-20.7c4.7,-2.4 11.3,-1.2 15.2,2.7c5.4,5.4 5.2,13.9 -0.3,19.1c-4.3,4 -9.4,4.4 -12.6,0.9c-1.7,-1.9 -2.2,-3.9 -1.7,-6.4c0.2,-1.1 0.3,-2 0.2,-2.2c-0.3,-0.3 -3.6,3.3 -8.3,9.1c-17.6,21.8 -28.5,48 -31.9,76.5c-1.1,9.3 -1,26.4 0.1,34.6c0.3,1.8 0.8,1.9 1.4,0.1c0.9,-2.6 4,-4.7 6.8,-4.7c3,-0 5.9,2.2 7.5,5.7c0.6,1.3 0.8,2.3 0.8,5.2c-0,3.3 -0.1,3.8 -1.1,5.7c-1.4,2.7 -4.6,5.7 -7.1,6.6c-2.5,0.9 -6.1,1.2 -8,0.8Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M790.1,691.5c-3.7,-0.6 -7.7,-3.6 -9.4,-7.1c-3.8,-7.5 0.1,-16.9 6.9,-16.9c3.1,0 5.8,2 6.9,5.2c0.4,1.2 0.5,1.3 0.7,0.7c1.3,-3.7 1.7,-26.4 0.6,-35.7c-3.6,-29.6 -14.5,-55.3 -33,-77.9c-5.5,-6.7 -8.4,-9.4 -7.1,-6.6c0.7,1.4 0.5,4.3 -0.3,5.9c-0.9,1.7 -3.2,3.5 -5,3.8c-3.2,0.6 -7.9,-1.6 -10.2,-4.8c-6.5,-8.8 -0.5,-21.2 10.4,-21.4c4.6,-0.1 5.2,0.3 11.2,6.4c12.1,12.3 21.1,24.9 28.8,40.3c13.2,26.3 18.6,54.9 16.1,84.5c-0.5,5.6 -2,15.7 -2.6,17.1c-1.3,2.8 -4.8,5.5 -8.4,6.5c-2.3,0.4 -3.1,0.4 -5.6,0Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M545.7,680.2c-6,-1.3 -12.2,-6.2 -14.9,-11.7c-3.4,-7 -3.1,-15.1 0.9,-21.6c0.7,-1.2 1.2,-2.3 1.1,-2.4c-0.1,-0.1 -1.1,-0.6 -2.1,-1c-3.9,-1.5 -8.1,-4.8 -10.7,-8.3c-4.6,-6.2 -6.1,-14.6 -3.9,-22.1c2.9,-10.3 9.4,-16.8 19.1,-19.3c2.8,-0.7 9,-0.8 11.7,-0c1.1,0.3 2.2,0.5 2.4,0.5c0.2,-0 0.3,-0.7 0.3,-1.5c0,-2.9 0.8,-5.8 2.4,-9.2c5.2,-10.8 18.1,-15.5 29,-10.5c2.7,1.2 6.2,3.8 7.8,5.8c0.7,0.8 10.3,14 21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1 -1.9,2.6 -2.5,3.5c-0.6,1 -1.2,1.7 -1.5,1.6c-4.5,-1.7 -46.7,-15 -47.7,-15c-1.9,0 -3.1,1.3 -3.1,3.2c0,1 0.2,1.7 0.8,2.3c0.6,0.6 7.8,3.1 24.5,8.5l23.7,7.7l-0.2,8.6l-32.6,10.5c-18,5.9 -33.9,10.9 -35.2,11.2c-3.1,0.7 -6.6,0.7 -9.6,0.1Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M740,679.8c-1.8,-0.5 -17.5,-5.6 -35,-11.3l-31.8,-10.4l1,-4.3l0,-4.3l22.6,-7.7c15,-4.9 24,-8 24.6,-8.5c0.7,-0.6 0.9,-1.1 0.9,-2.2c-0,-2 -1.2,-3.3 -3.1,-3.3c-0.9,-0 -10.5,2.9 -24.7,7.5c-12.8,4.1 -23.4,7.5 -23.6,7.5c-0.1,-0 -0.7,-0.8 -1.3,-1.9c-0.6,-1 -1.6,-2.5 -2.2,-3.2c-0.7,-0.7 -1.2,-1.5 -1.2,-1.6c0,-0.2 9.6,-13.5 21.4,-29.6c18.9,-26 21.6,-29.6 23.6,-31.1c5.7,-4.4 13.1,-5.8 19.7,-3.9c9,2.7 16.1,11.6 16.1,20.3c0,2.3 -0.1,2.3 3.1,1.5c4.7,-1.1 11.5,-0.5 16,1.5c4.6,2 9,6 11.5,10.2c2.1,3.6 3.9,9.4 4.2,13.2c0.3,5.2 -1.1,10.7 -4,15.3c-2.6,4.1 -7.8,8.3 -12.1,9.8c-0.9,0.3 -1.7,0.8 -1.7,1c0,0.2 0.4,1 0.9,1.7c2.4,3.6 3.6,7.7 3.5,12.7c0,5.8 -2.1,10.7 -6.4,15.1c-4,4.1 -8.9,6.3 -14.9,6.5c-3.3,0.4 -4.3,0.3 -7.1,-0.5Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M643.7,671.9c-6.1,-1.6 -11.4,-6.8 -13.2,-12.9c-0.7,-2.4 -0.7,-7.5 0,-9.9c1.7,-5.8 6.6,-10.8 12.3,-12.5c2.7,-0.8 7.2,-0.9 10,-0.2c6.2,1.6 11.6,7.1 13.2,13.3c1.6,6 -0.3,12.6 -5,17.3c-4.6,4.6 -11.3,6.5 -17.3,4.9Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M615.8,602.8c-13.3,-18.3 -21.2,-29.6 -22,-31.1c-1.4,-3 -1.9,-5.5 -1.9,-9.4c0,-14.1 13.1,-24.4 27.1,-21.4c1.4,0.3 2.6,0.5 2.7,0.5c0.1,0 0.3,-1.3 0.4,-2.8c0.8,-10.7 8.4,-19.6 18.9,-22.4c3.9,-1 10.6,-1 14.5,-0c8.9,2.3 15.9,9.3 18.2,18.2c0.4,1.5 0.7,3.7 0.7,4.9c-0,1.2 0.1,2.1 0.3,2.1c0.2,-0 1.5,-0.3 3,-0.6c7.4,-1.6 15.2,0.7 20.5,6c4.3,4.3 6.6,9.6 6.6,15.6c-0,4 -0.6,6.5 -2.4,10c-0.6,1.2 -10.4,15 -21.7,30.7c-17.8,24.5 -20.8,28.5 -21.4,28.3c-0.4,-0.1 -1.9,-0.6 -3.4,-1.1c-1.5,-0.5 -2.9,-0.9 -3.3,-0.9c-0.7,-0 -0.7,-0.8 -0.3,-25.5l-0,-25.5l-1.4,-0.9c-1,-1.1 -2.5,-1.5 -3.8,-0.9c-2,0.8 -2,-0.5 -1.8,27.2l-0,25.8l-1.2,-0c-0.5,-0.2 -2.4,0.3 -4,0.9c-1.6,0.6 -3.1,1.1 -3.2,1.1c-0.2,-0.1 -9.6,-13 -21.1,-28.8Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M578.4,537.8c-4.1,-0.9 -7.7,-3.6 -9.6,-7.4c-1.4,-2.8 -1.7,-7.3 -0.5,-10.3c1.7,-4.5 3.9,-6.1 15.6,-11.2c15.8,-7 31.4,-11.1 49.2,-12.9c7.3,-0.8 23.2,-0.8 30.6,0c17.4,1.8 33.3,6 49.1,13c7.3,3.2 12.5,6.1 13.6,7.5c4.3,5.6 3.8,12.7 -1.1,17.6c-5.1,5.1 -12.9,5.4 -18.1,0.7c-2,-1.8 -3,-3.5 -3.4,-5.6c-0.7,-4 2.9,-8.1 7.3,-8.2c1.4,0 1.5,-0.1 1.1,-0.5c-0.3,-0.3 -2.2,-1.2 -4.3,-2.1c-33.2,-14.5 -70.5,-16.4 -105,-5.4c-7.5,2.4 -19,7.2 -18.6,7.7c0.1,0.2 0.8,0.3 1.6,0.3c5.6,0 9.1,6.2 6.1,10.8c-2.9,4.5 -8.6,7.1 -13.6,6Z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M542.2,496.4c-8.9,-13.1 -16.8,-25.1 -17.5,-26.6c-1.6,-3.3 -3.6,-9.2 -4.4,-13c-2.6,-12.5 -0.9,-25.8 5,-37.5c4.2,-8.3 11.2,-16.3 18.6,-21.3c5,-3.4 6.1,-3.9 12.8,-6.3c23.1,-8.2 47.2,-13.1 73.4,-15c7.5,-0.6 28.5,-0.6 36.3,-0c25.5,1.8 50.6,6.9 73,14.8c6.4,2.2 8.2,3.1 13.1,6.5c9.8,6.6 18.1,17.5 22,29.2c2.2,6.5 2.7,10 2.7,17.9c0,7.9 -0.5,11.3 -2.7,17.9c-2.3,6.8 -3.7,9.1 -20.3,33.6l-16.1,23.8l-0.4,-2.2c-0.2,-1.2 -0.9,-3 -1.4,-4c-1,-1.8 -4.4,-5.6 -4.7,-5.2c-0.1,0.1 -1.2,-0.4 -2.4,-1.1c-9.1,-5.2 -21.9,-10.5 -33.2,-13.9c-37,-11 -77.2,-8.8 -113,6.1c-4.9,2.1 -17.7,8.4 -19.2,9.5c-2.2,1.6 -5.1,6.8 -5.1,9c0,0.4 -0.1,1 -0.3,1.2c0.1,0.2 -6.2,-8.8 -16.2,-23.4Z" />
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="m52.25,43.55c3.4,3.08 6.15,6.39 7.91,9.5 3.03,-5.55 5.06,-12.14 5.08,-16.34 0,-0.03 0,-0.06 0,-0.08 0,-6.21 -6.06,-8.63 -11.28,-8.63 -5.22,0 -11.28,2.42 -11.28,8.63 0,0.08 0,0.2 0,0.34 2.91,1.32 6.36,3.69 9.56,6.59z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="m33.68,60.5c2.13,-2.42 5.39,-5.05 9.08,-7.27 3.92,-2.36 7.84,-4.01 11.28,-4.76 -4.22,-4.67 -9.72,-8.67 -13.62,-10 -0.03,-0.01 -0.05,-0.02 -0.08,-0.03 -5.78,-1.92 -9.9,3.23 -11.51,8.31 -1.61,5.08 -1.24,11.72 4.54,13.64 0.08,0.03 0.18,0.06 0.31,0.1z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="M79.17,46.67C77.56,41.59 73.44,36.44 67.66,38.36c-0.08,0.03 -0.19,0.06 -0.31,0.1 -0.33,3.24 -1.46,7.33 -3.17,11.34 -1.81,4.27 -4.04,7.96 -6.39,10.64 6.1,1.24 12.85,1.17 16.76,-0.1 0.03,-0.01 0.05,-0.02 0.08,-0.03 5.78,-1.92 6.15,-8.56 4.54,-13.64z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="m48.82,65.46c-0.98,-4.54 -1.3,-8.87 -0.94,-12.45 -5.64,2.67 -11.07,6.78 -13.5,10.16 -0.02,0.02 -0.03,0.05 -0.05,0.07 -3.57,5.03 -0.06,10.63 4.16,13.77 4.22,3.14 10.51,4.83 14.09,-0.2 0.05,-0.07 0.11,-0.16 0.19,-0.27 -1.59,-2.82 -3.03,-6.81 -3.95,-11.08z" />
<path
android:fillColor="#fff"
android:fillType="nonZero"
android:pathData="m73.58,62.92c-3.11,0.68 -7.26,0.84 -11.52,0.42 -4.53,-0.45 -8.64,-1.47 -11.86,-2.93 0.73,6.31 2.88,12.87 5.28,16.28 0.02,0.02 0.03,0.05 0.05,0.07 3.57,5.03 9.86,3.34 14.09,0.2 4.22,-3.14 7.74,-8.74 4.16,-13.77 -0.05,-0.07 -0.11,-0.16 -0.19,-0.27z" />
</vector>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 129,
"android.injected.version.name" => "1.99.0",
"android.injected.version.code" => 130,
"android.injected.version.name" => "1.100.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+3 -3
View File
@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.32752">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.774783">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="26.041597">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="32.283066">
</testcase>
+5 -1
View File
@@ -283,6 +283,10 @@
"map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_theme_settings": "Map Theme",
"map_zoom_to_see_photos": "Zoom out to see photos",
"memories_all_caught_up": "All caught up",
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
"memories_start_over": "Start Over",
"memories_swipe_to_close": "Swipe up to close",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "Motion Photos",
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
@@ -482,4 +486,4 @@
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack"
}
}
+9 -14
View File
@@ -17,9 +17,6 @@ PODS:
- fluttertoast (0.0.2):
- Flutter
- Toast
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- geolocator_apple (1.2.0):
- Flutter
- image_picker_ios (0.0.1):
@@ -39,7 +36,7 @@ PODS:
- FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- permission_handler_apple (9.1.1):
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (2.0.0):
- Flutter
@@ -53,7 +50,7 @@ PODS:
- FlutterMacOS
- sqflite (0.0.3):
- Flutter
- FMDB (>= 2.7.5)
- FlutterMacOS
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
@@ -84,14 +81,13 @@ DEPENDENCIES:
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- FMDB
- MapLibre
- ReachabilitySwift
- SAMKeychain
@@ -139,7 +135,7 @@ EXTERNAL SOURCES:
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
:path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
@@ -156,23 +152,22 @@ SPEC CHECKSUMS:
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579
+4 -4
View File
@@ -172,7 +172,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@@ -383,7 +383,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -525,7 +525,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -553,7 +553,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
+2 -2
View File
@@ -55,11 +55,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.99.0</string>
<string>1.100.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>145</string>
<string>146</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.99.0"
version_number: "1.100.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
+6 -6
View File
@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000246">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000226">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.175843">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.183824">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.871371">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.799845">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.189451">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.185425">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="142.078248">
<testcase classname="fastlane.lanes" name="4: build_app" time="111.245268">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="78.774821">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="72.572736">
</testcase>
@@ -80,7 +80,6 @@ class _AssetDragRegionState extends State<AssetDragRegion> {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
recognizer.onLongPressCancel = _onLongPressEnd;
}
AssetIndex? _getValueKeyAtPositon(Offset position) {
@@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -55,27 +56,27 @@ class _MemoryEpilogueState extends State<MemoryEpilogue>
),
const SizedBox(height: 16.0),
Text(
'All caught up',
"memories_all_caught_up",
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
),
),
).tr(),
const SizedBox(height: 16.0),
Text(
'Check back tomorrow for more memories',
"memories_check_back_tomorrow",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white,
),
),
).tr(),
const SizedBox(height: 16.0),
TextButton(
onPressed: widget.onStartOver,
child: Text(
'Start Over',
"memories_start_over",
style: context.textTheme.displayMedium?.copyWith(
color: immichDarkThemePrimaryColor,
),
),
).tr(),
),
],
),
@@ -106,11 +107,11 @@ class _MemoryEpilogueState extends State<MemoryEpilogue>
),
),
Text(
'Swipe up to close',
"memories_swipe_to_close",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white,
),
),
).tr(),
],
),
),
@@ -1,13 +1,18 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
final recentlyAddedProvider = FutureProvider<List<Asset>>((ref) async {
final user = ref.read(currentUserProvider);
if (user == null) return [];
return ref
.watch(dbProvider)
.assets
.where()
.ownerIdEqualToAnyChecksum(user.isarId)
.sortByFileCreatedAtDesc()
.findAll();
});
+3
View File
@@ -169,6 +169,7 @@ doc/TagTypeEnum.md
doc/ThumbnailFormat.md
doc/TimeBucketResponseDto.md
doc/TimeBucketSize.md
doc/TimelineApi.md
doc/ToneMapping.md
doc/TranscodeHWAccel.md
doc/TranscodePolicy.md
@@ -211,6 +212,7 @@ lib/api/server_info_api.dart
lib/api/shared_link_api.dart
lib/api/system_config_api.dart
lib/api/tag_api.dart
lib/api/timeline_api.dart
lib/api/trash_api.dart
lib/api/user_api.dart
lib/api_client.dart
@@ -556,6 +558,7 @@ test/tag_type_enum_test.dart
test/thumbnail_format_test.dart
test/time_bucket_response_dto_test.dart
test/time_bucket_size_test.dart
test/timeline_api_test.dart
test/tone_mapping_test.dart
test/transcode_hw_accel_test.dart
test/transcode_policy_test.dart
+3 -3
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.99.0
- API version: 1.100.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -105,8 +105,6 @@ Class | Method | HTTP request | Description
*AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker |
*AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane |
*AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random |
*AssetApi* | [**getTimeBucket**](doc//AssetApi.md#gettimebucket) | **GET** /asset/time-bucket |
*AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets |
*AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs |
*AssetApi* | [**searchAssets**](doc//AssetApi.md#searchassets) | **GET** /assets |
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |
@@ -199,6 +197,8 @@ Class | Method | HTTP request | Description
*TagApi* | [**tagAssets**](doc//TagApi.md#tagassets) | **PUT** /tag/{id}/assets |
*TagApi* | [**untagAssets**](doc//TagApi.md#untagassets) | **DELETE** /tag/{id}/assets |
*TagApi* | [**updateTag**](doc//TagApi.md#updatetag) | **PATCH** /tag/{id} |
*TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket |
*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets |
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty |
*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets |
*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore |
-154
View File
@@ -23,8 +23,6 @@ Method | HTTP request | Description
[**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker |
[**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane |
[**getRandom**](AssetApi.md#getrandom) | **GET** /asset/random |
[**getTimeBucket**](AssetApi.md#gettimebucket) | **GET** /asset/time-bucket |
[**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets |
[**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs |
[**searchAssets**](AssetApi.md#searchassets) | **GET** /assets |
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |
@@ -833,158 +831,6 @@ 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)
# **getTimeBucket**
> List<AssetResponseDto> getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### 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 = AssetApi();
final size = ; // TimeBucketSize |
final timeBucket = timeBucket_example; // String |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getTimeBucket: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**timeBucket** | **String**| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<AssetResponseDto>**](AssetResponseDto.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)
# **getTimeBuckets**
> List<TimeBucketResponseDto> getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### 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 = AssetApi();
final size = ; // TimeBucketSize |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getTimeBuckets: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<TimeBucketResponseDto>**](TimeBucketResponseDto.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)
# **runAssetJobs**
> runAssetJobs(assetJobsDto)
+1
View File
@@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [default to const []]
**title** | **String** | |
**yearsAgo** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+4 -2
View File
@@ -119,7 +119,7 @@ This endpoint does not need any parameter.
[[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)
# **getMapStyle**
> Object getMapStyle(theme)
> Object getMapStyle(theme, key)
@@ -143,9 +143,10 @@ import 'package:openapi/api.dart';
final api_instance = SystemConfigApi();
final theme = ; // MapTheme |
final key = key_example; // String |
try {
final result = api_instance.getMapStyle(theme);
final result = api_instance.getMapStyle(theme, key);
print(result);
} catch (e) {
print('Exception when calling SystemConfigApi->getMapStyle: $e\n');
@@ -157,6 +158,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**theme** | [**MapTheme**](.md)| |
**key** | **String**| | [optional]
### Return type
+167
View File
@@ -0,0 +1,167 @@
# openapi.api.TimelineApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getTimeBucket**](TimelineApi.md#gettimebucket) | **GET** /timeline/bucket |
[**getTimeBuckets**](TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets |
# **getTimeBucket**
> List<AssetResponseDto> getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### 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 = TimelineApi();
final size = ; // TimeBucketSize |
final timeBucket = timeBucket_example; // String |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling TimelineApi->getTimeBucket: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**timeBucket** | **String**| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<AssetResponseDto>**](AssetResponseDto.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)
# **getTimeBuckets**
> List<TimeBucketResponseDto> getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### 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 = TimelineApi();
final size = ; // TimeBucketSize |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling TimelineApi->getTimeBuckets: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<TimeBucketResponseDto>**](TimeBucketResponseDto.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)
+1
View File
@@ -47,6 +47,7 @@ part 'api/server_info_api.dart';
part 'api/shared_link_api.dart';
part 'api/system_config_api.dart';
part 'api/tag_api.dart';
part 'api/timeline_api.dart';
part 'api/trash_api.dart';
part 'api/user_api.dart';
-249
View File
@@ -835,255 +835,6 @@ class AssetApi {
return null;
}
/// Performs an HTTP 'GET /asset/time-bucket' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/time-bucket';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
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<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'GET /asset/time-buckets' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/time-buckets';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
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<TimeBucketResponseDto>') as List)
.cast<TimeBucketResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response].
/// Parameters:
///
+10 -3
View File
@@ -102,7 +102,9 @@ class SystemConfigApi {
/// Parameters:
///
/// * [MapTheme] theme (required):
Future<Response> getMapStyleWithHttpInfo(MapTheme theme,) async {
///
/// * [String] key:
Future<Response> getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/system-config/map/style.json';
@@ -113,6 +115,9 @@ class SystemConfigApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
queryParams.addAll(_queryParams('', 'theme', theme));
const contentTypes = <String>[];
@@ -132,8 +137,10 @@ class SystemConfigApi {
/// Parameters:
///
/// * [MapTheme] theme (required):
Future<Object?> getMapStyle(MapTheme theme,) async {
final response = await getMapStyleWithHttpInfo(theme,);
///
/// * [String] key:
Future<Object?> getMapStyle(MapTheme theme, { String? key, }) async {
final response = await getMapStyleWithHttpInfo(theme, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+267
View File
@@ -0,0 +1,267 @@
//
// 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 TimelineApi {
TimelineApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/timeline/bucket';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
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<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/timeline/buckets';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
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<TimeBucketResponseDto>') as List)
.cast<TimeBucketResponseDto>()
.toList(growable: false);
}
return null;
}
}
+11 -3
View File
@@ -15,30 +15,36 @@ class MemoryLaneResponseDto {
MemoryLaneResponseDto({
this.assets = const [],
required this.title,
required this.yearsAgo,
});
List<AssetResponseDto> assets;
String title;
int yearsAgo;
@override
bool operator ==(Object other) => identical(this, other) || other is MemoryLaneResponseDto &&
_deepEquality.equals(other.assets, assets) &&
other.title == title;
other.title == title &&
other.yearsAgo == yearsAgo;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assets.hashCode) +
(title.hashCode);
(title.hashCode) +
(yearsAgo.hashCode);
@override
String toString() => 'MemoryLaneResponseDto[assets=$assets, title=$title]';
String toString() => 'MemoryLaneResponseDto[assets=$assets, title=$title, yearsAgo=$yearsAgo]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assets'] = this.assets;
json[r'title'] = this.title;
json[r'yearsAgo'] = this.yearsAgo;
return json;
}
@@ -52,6 +58,7 @@ class MemoryLaneResponseDto {
return MemoryLaneResponseDto(
assets: AssetResponseDto.listFromJson(json[r'assets']),
title: mapValueOfType<String>(json, r'title')!,
yearsAgo: mapValueOfType<int>(json, r'yearsAgo')!,
);
}
return null;
@@ -101,6 +108,7 @@ class MemoryLaneResponseDto {
static const requiredKeys = <String>{
'assets',
'title',
'yearsAgo',
};
}
-10
View File
@@ -95,16 +95,6 @@ void main() {
// TODO
});
//Future<List<AssetResponseDto>> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBucket', () async {
// TODO
});
//Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBuckets', () async {
// TODO
});
//Future runAssetJobs(AssetJobsDto assetJobsDto) async
test('test runAssetJobs', () async {
// TODO
+5
View File
@@ -26,6 +26,11 @@ void main() {
// TODO
});
// int yearsAgo
test('to test the property `yearsAgo`', () async {
// TODO
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ void main() {
// TODO
});
//Future<Object> getMapStyle(MapTheme theme) async
//Future<Object> getMapStyle(MapTheme theme, { String key }) async
test('test getMapStyle', () async {
// TODO
});

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