Compare commits

...

74 Commits

Author SHA1 Message Date
Alex The Bot
608543da0b Version v1.77.0 2023-09-06 03:30:44 +00:00
Maarten Rijke
b4fa60d4fd feat(web): show original uploader in shared album photo details (#3977)
* feat(web): show original uploader in shared album photo details

* feat: send owner in asset by id response

* chore: open api

* fix: linting

* fix: change to Shared By

* openapi

* openapi

* api

* styling

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-06 10:14:44 +07:00
dependabot[bot]
b1467bd1da chore(deps): bump actions/checkout from 3 to 4 (#3983)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-06 08:50:22 +07:00
Mert
3cf0f5f11b fix(ml): model downloading improvements (#3988)
* handle ort `NoSuchFile` error, stricter file check

* keep exception order
2023-09-06 08:48:40 +07:00
Jason Rasmussen
454737ca79 refactor(server): update asset endpoint (#3973)
* refactor(server): update asset

* chore: open api
2023-09-04 22:25:31 -04:00
Maarten Rijke
26bc889f8d feat(server): include shared albums in getByAssetId (#3978)
This commit changes the album.getByAssetId API to also consider
albums that have been shared with the current user.
This way when the user is browing their timeline and clicks to show
the asset details they will see if the asset appears in not only their
own albums but also albums shared with them.
2023-09-04 20:49:32 -04:00
Dhrumil Shah
54775b896f fix(mobile): change password page not navigating back (#3967) 2023-09-05 06:36:16 +07:00
Jason Rasmussen
9217fb4094 fix(web): skeleton loading (#3972) 2023-09-04 19:33:57 -04:00
Mert
04d4a30471 fix(server): await thumbnail generation before returning (#3975)
* await sharp command, minor fixes

* removed outdated test
2023-09-04 19:24:55 -04:00
shenlong
90f9501902 fix(mobile): curated places taking more size on large screens (#3959) 2023-09-05 06:10:27 +07:00
shenlong
f8d26bd865 fix(mobile): map markers not loading with int coordinates (#3957)
* fix(mobile): increase zoom-level for map zoom to asset

* refactor(mobile): map-view - rename lastAssetOffsetInSheet

* Workaround OpenAPI Dart generator bug

* fix(mobile): map - increase appbar top padding

* fix(mobile): navigation bar overlapping map bottom sheet

* fix(mobile): map - do not animate the drag handle of bottom sheet on scroll

* fix(mobile): F-Droid build failure due to map view

* fix(mobile): remove jank on map asset marker update

* fix(mobile): map view app-bar padding is made dynamic

* fix(mobile): reduce debounce time in bottom sheet asset scroll

* fix(mobile): bottom sheet - reduce drag handle total height

---------

Co-authored-by: Daniele Ricci <daniele@casaricci.it>
2023-09-05 06:08:43 +07:00
Jason Rasmussen
816d040d81 fix(server): lint import order (#3974)
* fix: use prettier extension

* chore: format fix
2023-09-04 21:45:59 +02:00
Mert
2069293cc1 feat(server): wide gamut thumbnails (#3658) 2023-09-03 02:21:51 -04:00
JasBogans
4bd77d5899 fix(web): sidebar artifact when toggle themes (#3955)
* fix for sidebar artifact when clicking the toggle

* Fix the delay in the search-bar

* format

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-03 08:31:12 +07:00
Mert
f8ff342852 feat(server): advanced settings for transcoding (#3775)
* set stream with `-map` flag

* updated tests

* fixed audio stream mapping

* added bframe setting to config

* updated api

* added b-frame option in dashboard

* updated tests and formatting

* "Advanced" section for FFmpeg with extra options

* updated api

* updated tests and formatting

* styling

* made vp9 bitstream filters conditional on b-frames

* fixed gop size condition

* add cq override

* simplified isEdited conditions

* simplified conditional flow for cq mode

* fixed dto

* clarified cq mode in description

* formatting

* added npl setting

* Adjusted b-frame title and description

* fixed rebase

* changed defaults for pascal compatibility, added temporal aq setting

* updated api

* added temporal aq to ui

* polished dashboard

* formatting
2023-09-03 08:22:42 +07:00
Patrick Eigensatz
67ac686704 docs: bulk upload: Fix "upload directory" instructions (#3942)
The command examples are titled "Upload current directory" and "Upload target directory", however the presented commands skip the `/import` directory, if the `--recursive` flag is not explicitly specified.
2023-09-02 02:06:40 +00:00
Jason Rasmussen
4e5bf7ae2e test: server-info e2e tests (#3948) 2023-09-01 22:01:54 -04:00
Mert
b7fd5dcb4a dev(ml): fixed docker-compose.dev.yml, updated locust (#3951)
* fixed dev docker compose

* updated locustfile

* deleted old script, moved comments to locustfile
2023-09-01 21:59:17 -04:00
Valonso
bea287c5b3 fix(web): images not loading on search and gallery (#3902)
* Check all observed entries, not only first

* fix: formatting

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-01 15:39:15 -04:00
JasBogans
46c716d450 feat(web): skeleton on asset loading (#3867)
* feat(web): skeletron on asset loading

* feat: add skeleton to all asset grid views

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-01 13:12:09 -04:00
Mert
9539a361e4 fix(server): non-nullable IsOptional (#3939)
* custom `IsOptional`

* added link to source

* formatting

* Update server/src/domain/domain.util.ts

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

* nullable birth date endpoint

* made `nullable` a property

* formatting

* removed unused dto

* updated decorator arg

* fixed album e2e tests

* add null tests for auth e2e

* add null test for person e2e

* fixed tests

* added null test for user e2e

* removed unusued import

* log key in test name

* chore: add note about mobile not being able to use the endpoint

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-01 16:40:00 +00:00
Villena Guillaume
ca35e5557b feat(web): Improved assets upload (#3850)
* Improved asset upload algorithm.

- Upload Queue: New process algorithm
- Upload Queue: Concurrency correctly respected when dragging / adding multiple group of files to the queue
- Upload Task: Add more information about progress (upload speed and remaining time)
- Upload Panel: Add more information to about the queue status (Remaining, Errors, Duplicated, Uploaded)
- Error recovery: asset information are kept in the queue to give the user a chance to read the error message
- Error recovery: on error allow the user to retry the upload or hide the error / all errors

* Support "live" editing of the upload concurrency

* Fixed some issues

* Reformat

* fix: merge, linting, dark mode, upload to share

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-09-01 12:00:51 -04:00
Jason Rasmussen
a26ed3d1a6 refactor(web,server): use feature flags for oauth (#3928)
* refactor: oauth to use feature flags

* chore: open api

* chore: e2e test for authorize endpoint
2023-09-01 18:08:42 +07:00
Daniele Ricci
c7d53a5006 docs: remove obsolete environment variables (#3926)
* Obsolete environment variables

* Review fixes

* Review fixes

* Review fixes
2023-09-01 18:05:45 +07:00
Mert
41461e0d5d chore(ml): memory optimisations (#3934) 2023-08-31 18:30:53 -05:00
Daniele Ricci
c0a48d7357 Create real anchors around admin sidebar buttons (#3925) 2023-08-31 18:26:40 -05:00
Daniele Ricci
66cc744c22 feat(web): face tooltips (#3924) 2023-08-31 18:25:13 -05:00
Alex The Bot
58ae734fc2 Version v1.76.1 2023-08-30 08:26:04 +00:00
Mert
54b2779b79 chore(ml): improved logging (#3918)
* fixed `minScore` not being set correctly

* apply to init

* don't send `enabled`

* fix eslint warning

* added logger

* added logging

* refinements

* enable access log for info level

* formatting

* merged strings

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-08-30 08:22:01 +00:00
Mert
df26e12db6 fix(ml): minScore not being set correctly (#3916)
* fixed `minScore` not being set correctly

* apply to init

* don't send `enabled`

* fix eslint warning

* better error message
2023-08-30 03:16:00 -05:00
Alex
343d89c032 chore: post release 2023-08-29 14:51:57 -05:00
Alex The Bot
49c2d4d115 Version v1.76.0 2023-08-29 19:24:43 +00:00
Alex Tran
58aefc928d Merge branch 'main' of github.com:immich-app/immich 2023-08-29 14:09:06 -05:00
Alex
70d8902737 Revert "feat(mobile): upload image assets before videos (#3872)" (#3910)
This reverts commit 912a13ea0d.
2023-08-29 14:08:53 -05:00
Alex Tran
78eeebf8e6 Revert "feat(mobile): upload image assets before videos (#3872)"
This reverts commit 912a13ea0d.
2023-08-29 14:07:59 -05:00
Daniele Ricci
585330b179 Refer to FFmpeg documentation (#3908)
* Fix unsecure URL

* Some links to FFmpeg docs
2023-08-29 13:54:06 -05:00
waclaw66
5b1ac27058 fix(web): null location (#3906) 2023-08-29 13:49:51 -05:00
Mert
bcc36d14a1 feat(ml)!: customizable ML settings (#3891)
* consolidated endpoints, added live configuration

* added ml settings to server

* added settings dashboard

* updated deps, fixed typos

* simplified modelconfig

updated tests

* Added ml setting accordion for admin page

updated tests

* merge `clipText` and `clipVision`

* added face distance setting

clarified setting

* add clip mode in request, dropdown for face models

* polished ml settings

updated descriptions

* update clip field on error

* removed unused import

* add description for image classification threshold

* pin safetensors for arm wheel

updated poetry lock

* moved dto

* set model type only in ml repository

* revert form-data package install

use fetch instead of axios

* added slotted description with link

updated facial recognition description

clarified effect of disabling tasks

* validation before model load

* removed unnecessary getconfig call

* added migration

* updated api

updated api

updated api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-08-29 08:58:00 -05:00
waclaw66
22f5e05060 fix(web, mobile): camera info (#3904)
* fix(web): camera lens info

* fix(mobile): camera lens ISO fix

* fix svelte-check
2023-08-29 08:57:20 -05:00
Mert
e510e733cd fix(server): extract motion photo android single frame (#3903) 2023-08-29 04:01:42 -05:00
Alex
d0a06739d8 chore(server): bump server dependencies (#3899)
* chore(server): bump server dependencies

* fix: test
2023-08-28 14:41:57 -05:00
dependabot[bot]
26c43617d1 chore(deps): bump docker/setup-buildx-action from 2.9.1 to 2.10.0 (#3897)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.9.1 to 2.10.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.9.1...v2.10.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-28 06:25:14 -05:00
Alex
0a89c7ffc4 chore(mobile): add more catch all error log detail (#3893) 2023-08-27 23:54:04 -05:00
Daniel Dietzler
7097cf6319 fix(admin-cli): Fixes immich-admin because npm bin does not like arguments (#3864)
* fix admin cli

* move script to bin, actually pass arguments

* remove accidentally created package-lock.json
2023-08-27 09:35:49 -05:00
shalong-tanwen
912a13ea0d feat(mobile): upload image assets before videos (#3872)
* feat(mobile): upload image assets before videos

* mobile: sort by creation date before uploading assets
2023-08-27 02:18:17 -05:00
shalong-tanwen
cb391342d7 feat(mobile): map view (#3661)
* feat(mobile): map page - add map view

* map: add map-markers

* feat(map): add relative date filter

* fix: do not let users scroll past map bounds

* fix: fetch relative date from store to state on init

* feat(mobile):re-fetch markers only on filter change

* feat(mobile) - asset bottom sheet in map page

* feat(mobile): display markers based on bottom sheet scroll

* fix: exif-bottom-sheet - rebase conflict

* feat(mobile): map-view - strongly typed map page events

* feat(map): zoom to asset

* chore: dart analyzer fixes

* map-page move attribution to top-right

* feat(mobile): map view - asset selection handling

* feat(mobile): map-view display map in places row

* fix: make asset marker icon responsive

* optimise map page rebuilds

* refactor(mobile): map page

* feat(mobile): map-view: Go to location

* map-view(mobile): minor refactor

* fix(mobile): Handle invalid coords gracefully

* small styling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-08-27 05:07:35 +00:00
Skyler Mäntysaari
305889f32b chore(web): Fixing up missing awaits (#3882)
* chore(web): Fixing up some missing awaits.

* chore(web/shared-viewer): Update import to shorted version.
2023-08-26 23:31:52 -05:00
Alex The Bot
f1027d7807 Version v1.75.2 2023-08-26 22:34:54 +00:00
Alex
2806ac6eb4 fix(web): refresh job page render incorrect job buttons (#3884)
* fix(web): refresh job page render incorrect job buttons

* lint
2023-08-26 17:32:22 -05:00
Alex The Bot
cc1fecfffd Version v1.75.1 2023-08-26 18:31:14 +00:00
Alex
e02817362c fix(server): initialization search service (#3879) 2023-08-26 13:29:34 -05:00
shalong-tanwen
1b0484fc46 fix(mobile): Widget overflow due to exception on logout (#3869) 2023-08-26 00:06:55 -05:00
Alex The Bot
6fe214a784 Version v1.75.0 2023-08-26 04:44:39 +00:00
Alex
e18a9f84a4 feat(web): slideshow mode (#3813)
* slideshow

slideshow for main screen

Added control buttons

update

close detail panel window sif opened

format

5 seconds

remove unused files

handle video player

format

* fix: restrict slideshow to timeline views

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-25 18:20:45 -05:00
Daniel Dietzler
59bb727636 feat(web, server): Ability to use config file instead of admin UI (#3836)
* implement method to read config file

* getConfig returns config file if present

* return isConfigFile for http requests

* disable elements if config file is used, show message if config file is set, copy existing config to clipboard

* fix allowing partial configuration files

* add new env variable to docs

* fix tests

* minor refactoring, address review

* adapt config type in frontend

* remove unnecessary imports

* move config file reading to system-config repo

* add documentation

* fix code formatting in system settings page

* add validator for config file

* fix formatting in docs

* update generated files

* throw error when trying to update config. e.g. via cli or api

* switch to feature flags for isConfigFile

* refactoring

* refactor: config file

* chore: open api

* feat: always show copy/export buttons

* fix: default flags

* refactor: copy to clipboard

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-25 13:44:52 -04:00
Le_Futuriste
20e0c03b39 feat(web): add link to external map in leaflet popup (#3847)
* feat(web): add link to external map in leaflet popup

Sometimes it's useful to open a geo location to an external map
application to not have to copy the coordinates manually.
Here I put a link to OpenStreetMap because it's what I personally use.
But I known some people would want to use something different. We could
instead link to geohacks (eg. https://geohack.toolforge.org/geohack.php?params=048.861085_N_0002.313158_E_globe:Earth)
or make it a configurable param.

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-25 13:19:49 +00:00
waclaw66
6d1567cf44 smaller album title (#3860) 2023-08-25 14:10:08 +02:00
waclaw66
dc3f53a973 album and face menu dots visible on hover only (#3859) 2023-08-25 06:35:52 -05:00
waclaw66
dad7cf47b4 fix(web): delete album consolidation (#3858) 2023-08-25 13:03:16 +02:00
Mert
165b91b068 feat(ml)!: switch image classification and CLIP models to ONNX (#3809) 2023-08-25 06:28:51 +02:00
Jason Rasmussen
8211afb726 feat(web,server)!: configure machine learning via the UI (#3768) 2023-08-25 06:15:03 +02:00
James58899
2cccef174a fix(mobile): missing conversion to UTC time zone (#3495) 2023-08-25 06:08:19 +02:00
Jason Rasmussen
9bbef4a97b refactor(web): shared link key auth (#3855) 2023-08-25 06:03:28 +02:00
Jason Rasmussen
10c2bda3a9 chore: remove without thumbs (#3529)
* refactor(server): remove withoutThumbs

* chore: open api

* fix: bad merge
2023-08-24 21:45:54 -04:00
Fynn Petersen-Frey
cf9e04c8ec feat(server): asset entity audit (#3824)
* feat(server): audit log

* feedback

* Insert to database

* migration

* test

* controller/repository/service

* test

* module

* feat(server): implement audit endpoint

* directly return changed assets

* add daily cleanup of audit table

* fix tests

* review feedback

* ci

* refactor(server): audit implementation

* chore: open api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-08-24 15:28:50 -04:00
Daniele Ricci
d6887117ac chore(web): improve drop shadow on three-dots icon (#3835) 2023-08-23 07:20:50 +02:00
Alex
3b11be2859 fix(web): cannot view publlic shared album (#3829) 2023-08-22 08:05:48 +02:00
Alex
d7f52739e8 fix(web): shared link return 404 (#3791) 2023-08-22 07:22:49 +02:00
waclaw66
71ea46d95e fix(web): merge face thumbnail (#3822) 2023-08-22 04:34:53 +02:00
Daniele Ricci
e2afc43506 Use proper text/drop shadow on tree-dots icon and face name (#3800) 2023-08-20 18:36:31 -05:00
waclaw66
6aed1180e7 fix(web): album list padding (#3790) 2023-08-20 18:30:52 -05:00
Flyot
476b735e3c fix(web): ContextMenu unsatisfying UI behaviors (#3787) 2023-08-20 18:28:25 -05:00
Mert
7ad12c7f33 use camera wb for raw (#3806) 2023-08-20 18:26:01 -05:00
Mert
60729a091a make lazy loading default (#3797) 2023-08-20 18:24:14 -05:00
406 changed files with 19444 additions and 8422 deletions

View File

@@ -31,7 +31,7 @@ jobs:
ref="${input_ref:-$github_ref}"
echo "ref=$ref" >> $GITHUB_OUTPUT
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
ref: ${{ steps.get-ref.outputs.ref }}

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Cleanup
run: |

View File

@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -36,13 +36,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.9.1
uses: docker/setup-buildx-action@v2.10.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
@@ -120,13 +120,13 @@ jobs:
platforms: "linux/arm64,linux/amd64"
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.9.1
uses: docker/setup-buildx-action@v2.10.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761

View File

@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
token: ${{ secrets.ORG_RELEASE_TOKEN }}
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
token: ${{ secrets.ORG_RELEASE_TOKEN }}

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
@@ -37,7 +37,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
@@ -59,7 +59,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
@@ -89,7 +89,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
@@ -115,7 +115,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
@@ -144,7 +144,7 @@ jobs:
name: Run mobile unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
@@ -161,7 +161,7 @@ jobs:
run:
working-directory: ./machine-learning
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
@@ -171,6 +171,7 @@ jobs:
- name: Install dependencies
run: |
poetry install --with dev
poetry run pip install --no-deps -r requirements.txt
- name: Lint with ruff
run: |
poetry run ruff check --format=github app
@@ -188,7 +189,7 @@ jobs:
name: Check generated files are up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Run API generation
run: npm --prefix server run api:generate
- name: Find file changes
@@ -223,7 +224,7 @@ jobs:
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install server dependencies
run: npm --prefix server ci
- name: Run existing migrations
@@ -248,7 +249,7 @@ jobs:
# name: Run mobile end-to-end integration tests
# runs-on: macos-latest
# steps:
# - uses: actions/checkout@v3
# - uses: actions/checkout@v4
# - uses: actions/setup-java@v3
# with:
# distribution: 'zulu'

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.74.0
* The version of the OpenAPI document: 1.77.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.74.0
* The version of the OpenAPI document: 1.77.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.74.0
* The version of the OpenAPI document: 1.77.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.74.0
* The version of the OpenAPI document: 1.77.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -34,7 +34,7 @@ services:
ports:
- 3003:3003
volumes:
- ../machine-learning/app:/usr/src/app
- ../machine-learning:/usr/src/app
- model-cache:/cache
env_file:
- .env

View File

@@ -39,7 +39,7 @@ This often happens when using a reverse proxy or cloudflare tunnel in front of I
### Why is Immich slow on low-memory systems like the Raspberry Pi?
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file.
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_ENABLED=false` in your .env file.
### How to disable machine-learning and TypeSense?
@@ -47,7 +47,7 @@ Immich uses optional machine-learning features to enhance search results. This f
Disabling both will result in poor search experience and typesense utilizes CLIP embeddings which are generated by machine-learning.
:::
These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_URL=false` & `TYPESENSE_ENABLED=false` in your .env file.
These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_ENABLED=false` & `TYPESENSE_ENABLED=false` in your .env file.
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?

View File

@@ -68,16 +68,16 @@ Be aware that as this runs inside a container, you need to mount the folder from
```bash title="Upload current directory"
cd /DIRECTORY/WITH/IMAGES
docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
```bash title="Upload target directory"
docker run -it --rm -v "/DIRECTORY/WITH/IMAGES:/import" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
docker run -it --rm -v "/DIRECTORY/WITH/IMAGES:/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
```bash title="Create an alias"
alias immich='docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest'
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
immich upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
:::tip Internal networking
@@ -88,7 +88,7 @@ If you are running the CLI container on the same machine as your Immich server,
3. Use `--server http://immich-server:3001` for the upload command instead of the external address.
```bash title="Upload to internal address"
docker run --network immich_default -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://immich-server:3001
docker run --network immich_default -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://immich-server:3001
```
:::

View File

@@ -0,0 +1,91 @@
# Config File
A config file can be provided as an alternative to the UI configuration.
### Step 1 - Create a new config file
In JSON format, create a new config file (e.g. `immich.config`) and put it in a location that can be accessed by Immich.
The default configuration looks like this:
```json
{
"ffmpeg": {
"crf": 23,
"threads": 0,
"preset": "ultrafast",
"targetVideoCodec": "h264",
"targetAudioCodec": "aac",
"targetResolution": "720",
"maxBitrate": "0",
"twoPass": false,
"transcode": "required",
"tonemap": "hable",
"accel": "disabled"
},
"job": {
"backgroundTask": {
"concurrency": 5
},
"clipEncoding": {
"concurrency": 2
},
"metadataExtraction": {
"concurrency": 5
},
"objectTagging": {
"concurrency": 2
},
"recognizeFaces": {
"concurrency": 2
},
"search": {
"concurrency": 5
},
"sidecar": {
"concurrency": 5
},
"storageTemplateMigration": {
"concurrency": 5
},
"thumbnailGeneration": {
"concurrency": 5
},
"videoConversion": {
"concurrency": 1
}
},
"oauth": {
"enabled": false,
"issuerUrl": "",
"clientId": "",
"clientSecret": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"scope": "openid email profile",
"storageLabelClaim": "preferred_username",
"buttonText": "Login with OAuth",
"autoRegister": true,
"autoLaunch": false
},
"passwordLogin": {
"enabled": true
},
"storageTemplate": {
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
},
"thumbnail": {
"webpSize": 250,
"jpegSize": 1440
}
}
```
:::tip
In Administration > Settings is a button to copy the current configuration to your clipboard.
So you can just grab it from there, paste it into a file and you're pretty much good to go.
:::
### Step 2 - Specify the file location
In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section.

View File

@@ -132,7 +132,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
IMMICH_WEB_URL=http://immich-web:3000
IMMICH_SERVER_URL=http://immich-server:3001
IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
####################################################################################
# Alternative API's External Address - Optional

View File

@@ -1,3 +1,7 @@
---
sidebar_position: 90
---
# Environment Variables
## Docker Compose
@@ -22,6 +26,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices |
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices |
| `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message | | web |
| `IMMICH_CONFIG_FILE` | Path to config file | | server |
:::tip
@@ -41,22 +46,22 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## Ports
| Variable | Description | Default | Services |
| :---------------------- | :-------------------- | :-----: | :--------------- |
| `PORT` | Web Port | `3000` | web |
| `SERVER_PORT` | Server Port | `3001` | server |
| `MICROSERVICES_PORT` | Microservices Port | `3002` | microservices |
| `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning |
| Variable | Description | Default | Services |
| :---------------------- | :-------------------- | :-------: | :--------------- |
| `PORT` | Web Port | `3000` | web |
| `SERVER_PORT` | Server Port | `3001` | server |
| `MICROSERVICES_PORT` | Microservices Port | `3002` | microservices |
| `MACHINE_LEARNING_HOST` | Machine Learning Host | `0.0.0.0` | machine learning |
| `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning |
## URLs
| Variable | Description | Default | Services |
| :---------------------------- | :------------------------------------------------------- | :-----------------------------------: | :-------------------- |
| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy |
| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy |
| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, set `"false"` to disable ML | `http://immich-machine-learning:3003` | server, microservices |
| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web |
| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web |
| Variable | Description | Default | Services |
| :------------------------- | :---------------------- | :-------------------------: | :--------- |
| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy |
| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy |
| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web |
| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web |
:::info
@@ -172,18 +177,21 @@ Typesense URL example JSON before encoding:
## Machine Learning
| Variable | Description | Default | Services |
| :------------------------------------------ | :----------------------------- | :-------------------: | :--------------- |
| `MACHINE_LEARNING_MIN_FACE_SCORE` | Minimum Face Score | `0.7` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL` | Model TTL | `300` | machine learning |
| `MACHINE_LEARNING_EAGER_STARTUP` | Eager Startup | `true` | machine learning |
| `MACHINE_LEARNING_MIN_TAG_SCORE` | Minimum Tag Score | `0.9` | machine learning |
| `MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL` | Facial Recognition Model | `buffalo_l` | machine learning |
| `MACHINE_LEARNING_CLIP_TEXT_MODEL` | Clip Text Model | `clip-ViT-B-32` | machine learning |
| `MACHINE_LEARNING_CLIP_IMAGE_MODEL` | Clip Image Model | `clip-ViT-B-32` | machine learning |
| `MACHINE_LEARNING_CLASSIFICATION_MODEL` | Classification Model | `microsoft/resnet-50` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | ML Cache Location | `/cache` | machine learning |
| `TRANSFORMERS_CACHE` | ML Transformers Cache Location | `/cache` | machine learning |
| Variable | Description | Default | Services |
| :----------------------------------------------- | :----------------------------------------- | :-----------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Model TTL | `300` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | ML Cache Location | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Request thread pool size | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
:::info
Other machine learning parameters can be tuned from the admin UI.
:::
## Docker Secrets

View File

@@ -1,5 +1,5 @@
---
sidebar_position: 100
sidebar_position: 80
---
import RegisterAdminUser from '../partials/_register-admin.md';

View File

@@ -1,4 +1,4 @@
FROM python:3.11.4-bullseye@sha256:5b401676aff858495a5c9c726c60b8b73fe52833e9e16eccdb59e93d52741727 as builder
FROM python:3.11-bookworm as builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@@ -10,12 +10,13 @@ RUN poetry config installer.max-workers 10 && \
RUN python -m venv /opt/venv
ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml ./
COPY poetry.lock pyproject.toml requirements.txt ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
RUN pip install --no-deps -r requirements.txt
FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
ENV NODE_ENV=production \
@@ -26,6 +27,7 @@ ENV NODE_ENV=production \
PYTHONPATH=/usr/src
COPY --from=builder /opt/venv /opt/venv
COPY start.sh log_conf.json ./
COPY app .
ENTRYPOINT ["tini", "--"]
CMD ["python", "-m", "app.main"]
CMD ["./start.sh"]

View File

@@ -1,32 +1,69 @@
import logging
import os
from pathlib import Path
import gunicorn
import starlette
from pydantic import BaseSettings
from rich.console import Console
from rich.logging import RichHandler
from .schemas import ModelType
class Settings(BaseSettings):
cache_folder: str = "/cache"
classification_model: str = "microsoft/resnet-50"
clip_image_model: str = "clip-ViT-B-32"
clip_text_model: str = "clip-ViT-B-32"
facial_recognition_model: str = "buffalo_l"
min_tag_score: float = 0.9
eager_startup: bool = True
model_ttl: int = 0
host: str = "0.0.0.0"
port: int = 3003
workers: int = 1
min_face_score: float = 0.7
test_full: bool = False
request_threads: int = os.cpu_count() or 4
model_inter_op_threads: int = 1
model_intra_op_threads: int = 2
class Config:
env_prefix = "MACHINE_LEARNING_"
case_sensitive = False
def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
return Path(settings.cache_folder, model_type.value, model_name)
class LogSettings(BaseSettings):
log_level: str = "info"
no_color: bool = False
class Config:
case_sensitive = False
_clean_name = str.maketrans(":\\/", "___", ".")
def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
return Path(settings.cache_folder) / model_type.value / model_name.translate(_clean_name)
LOG_LEVELS: dict[str, int] = {
"critical": logging.ERROR,
"error": logging.ERROR,
"warning": logging.WARNING,
"warn": logging.WARNING,
"info": logging.INFO,
"log": logging.INFO,
"debug": logging.DEBUG,
"verbose": logging.DEBUG,
}
settings = Settings()
log_settings = LogSettings()
class CustomRichHandler(RichHandler):
def __init__(self) -> None:
console = Console(color_system="standard", no_color=log_settings.no_color)
super().__init__(
show_path=False, omit_repeated_times=False, console=console, tracebacks_suppress=[gunicorn, starlette]
)
log = logging.getLogger("gunicorn.access")
log.setLevel(LOG_LEVELS.get(log_settings.log_level.lower(), logging.INFO))

View File

@@ -1,58 +1,42 @@
import os
from io import BytesIO
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import cv2
import numpy as np
import uvicorn
from fastapi import Body, Depends, FastAPI
from PIL import Image
import orjson
from fastapi import FastAPI, Form, HTTPException, UploadFile
from fastapi.responses import ORJSONResponse
from starlette.formparsers import MultiPartParser
from .config import settings
from app.models.base import InferenceModel
from .config import log, settings
from .models.cache import ModelCache
from .schemas import (
EmbeddingResponse,
FaceResponse,
MessageResponse,
ModelType,
TagResponse,
TextModelRequest,
TextResponse,
)
MultiPartParser.max_file_size = 2**24 # spools to disk if payload is 16 MiB or larger
app = FastAPI()
def init_state() -> None:
app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
async def load_models() -> None:
models = [
(settings.classification_model, ModelType.IMAGE_CLASSIFICATION),
(settings.clip_image_model, ModelType.CLIP),
(settings.clip_text_model, ModelType.CLIP),
(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION),
]
# Get all models
for model_name, model_type in models:
await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup)
log.info(
(
"Created in-memory cache with unloading "
f"{f'after {settings.model_ttl}s of inactivity' if settings.model_ttl > 0 else 'disabled'}."
)
)
# asyncio is a huge bottleneck for performance, so we use a thread pool to run blocking code
app.state.thread_pool = ThreadPoolExecutor(settings.request_threads) if settings.request_threads > 0 else None
log.info(f"Initialized request thread pool with {settings.request_threads} threads.")
@app.on_event("startup")
async def startup_event() -> None:
init_state()
await load_models()
def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
return Image.open(BytesIO(byte_image))
def dep_cv_image(byte_image: bytes = Body(...)) -> cv2.Mat:
byte_image_np = np.frombuffer(byte_image, np.uint8)
return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
@app.get("/", response_model=MessageResponse)
@@ -65,62 +49,28 @@ def ping() -> str:
return "pong"
@app.post(
"/image-classifier/tag-image",
response_model=TagResponse,
status_code=200,
)
async def image_classification(
image: Image.Image = Depends(dep_pil_image),
) -> list[str]:
model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
labels = model.predict(image)
return labels
@app.post("/predict")
async def predict(
model_name: str = Form(alias="modelName"),
model_type: ModelType = Form(alias="modelType"),
options: str = Form(default="{}"),
text: str | None = Form(default=None),
image: UploadFile | None = None,
) -> Any:
if image is not None:
inputs: str | bytes = await image.read()
elif text is not None:
inputs = text
else:
raise HTTPException(400, "Either image or text must be provided")
model: InferenceModel = await app.state.model_cache.get(model_name, model_type, **orjson.loads(options))
outputs = await run(model, inputs)
return ORJSONResponse(outputs)
@app.post(
"/sentence-transformer/encode-image",
response_model=EmbeddingResponse,
status_code=200,
)
async def clip_encode_image(
image: Image.Image = Depends(dep_pil_image),
) -> list[float]:
model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP)
embedding = model.predict(image)
return embedding
@app.post(
"/sentence-transformer/encode-text",
response_model=EmbeddingResponse,
status_code=200,
)
async def clip_encode_text(payload: TextModelRequest) -> list[float]:
model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP)
embedding = model.predict(payload.text)
return embedding
@app.post(
"/facial-recognition/detect-faces",
response_model=FaceResponse,
status_code=200,
)
async def facial_recognition(
image: cv2.Mat = Depends(dep_cv_image),
) -> list[dict[str, Any]]:
model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION)
faces = model.predict(image)
return faces
if __name__ == "__main__":
is_dev = os.getenv("NODE_ENV") == "development"
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
reload=is_dev,
workers=settings.workers,
)
async def run(model: InferenceModel, inputs: Any) -> Any:
if app.state.thread_pool is not None:
return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
else:
return model.predict(inputs)

View File

@@ -1,3 +1,3 @@
from .clip import CLIPSTEncoder
from .clip import CLIPEncoder
from .facial_recognition import FaceRecognizer
from .image_classification import ImageClassifier

View File

@@ -1,14 +1,16 @@
from __future__ import annotations
import pickle
from abc import ABC, abstractmethod
from pathlib import Path
from shutil import rmtree
from typing import Any
from zipfile import BadZipFile
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf # type: ignore
import onnxruntime as ort
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile # type: ignore
from ..config import get_cache_dir
from ..config import get_cache_dir, log, settings
from ..schemas import ModelType
@@ -16,20 +18,60 @@ class InferenceModel(ABC):
_model_type: ModelType
def __init__(
self, model_name: str, cache_dir: Path | str | None = None, eager: bool = True, **model_kwargs: Any
self,
model_name: str,
cache_dir: Path | str | None = None,
eager: bool = True,
inter_op_num_threads: int = settings.model_inter_op_threads,
intra_op_num_threads: int = settings.model_intra_op_threads,
**model_kwargs: Any,
) -> None:
self.model_name = model_name
self._loaded = False
self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
loader = self.load if eager else self.download
self.providers = model_kwargs.pop("providers", ["CPUExecutionProvider"])
# don't pre-allocate more memory than needed
self.provider_options = model_kwargs.pop(
"provider_options", [{"arena_extend_strategy": "kSameAsRequested"}] * len(self.providers)
)
log.debug(
(
f"Setting '{self.model_name}' execution providers to {self.providers}"
"in descending order of preference"
),
)
log.debug(f"Setting execution provider options to {self.provider_options}")
self.sess_options = PicklableSessionOptions()
# avoid thread contention between models
if inter_op_num_threads > 1:
self.sess_options.execution_mode = ort.ExecutionMode.ORT_PARALLEL
log.debug(f"Setting execution_mode to {self.sess_options.execution_mode.name}")
log.debug(f"Setting inter_op_num_threads to {inter_op_num_threads}")
log.debug(f"Setting intra_op_num_threads to {intra_op_num_threads}")
self.sess_options.inter_op_num_threads = inter_op_num_threads
self.sess_options.intra_op_num_threads = intra_op_num_threads
self.sess_options.enable_cpu_mem_arena = False
try:
loader(**model_kwargs)
except (OSError, InvalidProtobuf, BadZipFile):
except (OSError, InvalidProtobuf, BadZipFile, NoSuchFile):
log.warn(
(
f"Failed to load {self.model_type.replace('_', ' ')} model '{self.model_name}'."
"Clearing cache and retrying."
)
)
self.clear_cache()
loader(**model_kwargs)
def download(self, **model_kwargs: Any) -> None:
if not self.cached:
log.info(
(f"Downloading {self.model_type.replace('_', ' ')} model '{self.model_name}'." "This may take a while.")
)
self._download(**model_kwargs)
def load(self, **model_kwargs: Any) -> None:
@@ -37,15 +79,21 @@ class InferenceModel(ABC):
self._load(**model_kwargs)
self._loaded = True
def predict(self, inputs: Any) -> Any:
def predict(self, inputs: Any, **model_kwargs: Any) -> Any:
if not self._loaded:
log.info(f"Loading {self.model_type.replace('_', ' ')} model '{self.model_name}'")
self.load()
if model_kwargs:
self.configure(**model_kwargs)
return self._predict(inputs)
@abstractmethod
def _predict(self, inputs: Any) -> Any:
...
def configure(self, **model_kwargs: Any) -> None:
pass
@abstractmethod
def _download(self, **model_kwargs: Any) -> None:
...
@@ -80,12 +128,33 @@ class InferenceModel(ABC):
def clear_cache(self) -> None:
if not self.cache_dir.exists():
log.warn(
f"Attempted to clear cache for model '{self.model_name}' but cache directory does not exist.",
)
return
if not rmtree.avoids_symlink_attacks:
raise RuntimeError("Attempted to clear cache, but rmtree is not safe on this platform.")
if self.cache_dir.is_dir():
log.info(f"Cleared cache directory for model '{self.model_name}'.")
rmtree(self.cache_dir)
else:
log.warn(
(
f"Encountered file instead of directory at cache path "
f"for '{self.model_name}'. Removing file and replacing with a directory."
),
)
self.cache_dir.unlink()
self.cache_dir.mkdir(parents=True, exist_ok=True)
# HF deep copies configs, so we need to make session options picklable
class PicklableSessionOptions(ort.SessionOptions):
def __getstate__(self) -> bytes:
return pickle.dumps([(attr, getattr(self, attr)) for attr in dir(self) if not callable(getattr(self, attr))])
def __setstate__(self, state: Any) -> None:
self.__init__() # type: ignore
for attr, val in pickle.loads(state):
setattr(self, attr, val)

View File

@@ -46,7 +46,7 @@ class ModelCache:
model: The requested model.
"""
key = self.cache.build_key(model_name, model_type.value)
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
async with OptimisticLock(self.cache, key) as lock:
model = await self.cache.get(key)
if model is None:

View File

@@ -1,31 +1,152 @@
from typing import Any
import os
import zipfile
from io import BytesIO
from typing import Any, Literal
from PIL.Image import Image
from sentence_transformers import SentenceTransformer
from sentence_transformers.util import snapshot_download
import onnxruntime as ort
import torch
from clip_server.model.clip import BICUBIC, _convert_image_to_rgb
from clip_server.model.clip_onnx import _MODELS, _S3_BUCKET_V2, CLIPOnnxModel, download_model
from clip_server.model.pretrained_models import _VISUAL_MODEL_IMAGE_SIZE
from clip_server.model.tokenization import Tokenizer
from PIL import Image
from torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
from ..config import log
from ..schemas import ModelType
from .base import InferenceModel
_ST_TO_JINA_MODEL_NAME = {
"clip-ViT-B-16": "ViT-B-16::openai",
"clip-ViT-B-32": "ViT-B-32::openai",
"clip-ViT-B-32-multilingual-v1": "M-CLIP/XLM-Roberta-Large-Vit-B-32",
"clip-ViT-L-14": "ViT-L-14::openai",
}
class CLIPSTEncoder(InferenceModel):
class CLIPEncoder(InferenceModel):
_model_type = ModelType.CLIP
def __init__(
self,
model_name: str,
cache_dir: str | None = None,
mode: Literal["text", "vision"] | None = None,
**model_kwargs: Any,
) -> None:
if mode is not None and mode not in ("text", "vision"):
raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
if "vit-b" not in model_name.lower():
raise ValueError(f"Only ViT-B models are currently supported; got '{model_name}'")
self.mode = mode
jina_model_name = self._get_jina_model_name(model_name)
super().__init__(jina_model_name, cache_dir, **model_kwargs)
def _download(self, **model_kwargs: Any) -> None:
repo_id = self.model_name if "/" in self.model_name else f"sentence-transformers/{self.model_name}"
snapshot_download(
cache_dir=self.cache_dir,
repo_id=repo_id,
library_name="sentence-transformers",
ignore_files=["flax_model.msgpack", "rust_model.ot", "tf_model.h5"],
)
models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
text_onnx_path = self.cache_dir / "textual.onnx"
vision_onnx_path = self.cache_dir / "visual.onnx"
if not text_onnx_path.is_file():
self._download_model(*models[0])
if not vision_onnx_path.is_file():
self._download_model(*models[1])
def _load(self, **model_kwargs: Any) -> None:
self.model = SentenceTransformer(
self.model_name,
cache_folder=self.cache_dir.as_posix(),
**model_kwargs,
)
if self.mode == "text" or self.mode is None:
self.text_model = ort.InferenceSession(
self.cache_dir / "textual.onnx",
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
self.text_outputs = [output.name for output in self.text_model.get_outputs()]
self.tokenizer = Tokenizer(self.model_name)
def _predict(self, image_or_text: Image | str) -> list[float]:
return self.model.encode(image_or_text).tolist()
if self.mode == "vision" or self.mode is None:
self.vision_model = ort.InferenceSession(
self.cache_dir / "visual.onnx",
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
self.vision_outputs = [output.name for output in self.vision_model.get_outputs()]
image_size = _VISUAL_MODEL_IMAGE_SIZE[CLIPOnnxModel.get_model_name(self.model_name)]
self.transform = _transform_pil_image(image_size)
def _predict(self, image_or_text: Image.Image | str) -> list[float]:
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")
pixel_values = self.transform(image_or_text)
assert isinstance(pixel_values, torch.Tensor)
pixel_values = torch.unsqueeze(pixel_values, 0).numpy()
outputs = self.vision_model.run(self.vision_outputs, {"pixel_values": pixel_values})
case str():
if self.mode == "vision":
raise TypeError("Cannot encode text as vision-only model")
text_inputs: dict[str, torch.Tensor] = self.tokenizer(image_or_text)
inputs = {
"input_ids": text_inputs["input_ids"].int().numpy(),
"attention_mask": text_inputs["attention_mask"].int().numpy(),
}
outputs = self.text_model.run(self.text_outputs, inputs)
case _:
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
return outputs[0][0].tolist()
def _get_jina_model_name(self, model_name: str) -> str:
if model_name in _MODELS:
return model_name
elif model_name in _ST_TO_JINA_MODEL_NAME:
log.warn(
(
f"Sentence-Transformer models like '{model_name}' are not supported."
f"Using '{_ST_TO_JINA_MODEL_NAME[model_name]}' instead as it is the best match for '{model_name}'."
),
)
return _ST_TO_JINA_MODEL_NAME[model_name]
else:
raise ValueError(f"Unknown model name {model_name}.")
def _download_model(self, model_name: str, model_md5: str) -> bool:
# downloading logic is adapted from clip-server's CLIPOnnxModel class
download_model(
url=_S3_BUCKET_V2 + model_name,
target_folder=self.cache_dir.as_posix(),
md5sum=model_md5,
with_resume=True,
)
file = self.cache_dir / model_name.split("/")[1]
if file.suffix == ".zip":
with zipfile.ZipFile(file, "r") as zip_ref:
zip_ref.extractall(self.cache_dir)
os.remove(file)
return True
@property
def cached(self) -> bool:
return (self.cache_dir / "textual.onnx").is_file() and (self.cache_dir / "visual.onnx").is_file()
# same as `_transform_blob` without `_blob2image`
def _transform_pil_image(n_px: int) -> Compose:
return Compose(
[
Resize(n_px, interpolation=BICUBIC),
CenterCrop(n_px),
_convert_image_to_rgb,
ToTensor(),
Normalize(
(0.48145466, 0.4578275, 0.40821073),
(0.26862954, 0.26130258, 0.27577711),
),
]
)

View File

@@ -4,11 +4,11 @@ from typing import Any
import cv2
import numpy as np
import onnxruntime as ort
from insightface.model_zoo import ArcFaceONNX, RetinaFace
from insightface.utils.face_align import norm_crop
from insightface.utils.storage import BASE_REPO_URL, download_file
from ..config import settings
from ..schemas import ModelType
from .base import InferenceModel
@@ -19,11 +19,11 @@ class FaceRecognizer(InferenceModel):
def __init__(
self,
model_name: str,
min_score: float = settings.min_face_score,
min_score: float = 0.7,
cache_dir: Path | str | None = None,
**model_kwargs: Any,
) -> None:
self.min_score = min_score
self.min_score = model_kwargs.pop("minScore", min_score)
super().__init__(model_name, cache_dir, **model_kwargs)
def _download(self, **model_kwargs: Any) -> None:
@@ -42,21 +42,39 @@ class FaceRecognizer(InferenceModel):
rec_file = next(self.cache_dir.glob("w600k_*.onnx"))
except StopIteration:
raise FileNotFoundError("Facial recognition models not found in cache directory")
self.det_model = RetinaFace(det_file.as_posix())
self.rec_model = ArcFaceONNX(rec_file.as_posix())
self.det_model = RetinaFace(
session=ort.InferenceSession(
det_file.as_posix(),
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
),
)
self.rec_model = ArcFaceONNX(
rec_file.as_posix(),
session=ort.InferenceSession(
rec_file.as_posix(),
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
),
)
self.det_model.prepare(
ctx_id=-1,
ctx_id=0,
det_thresh=self.min_score,
input_size=(640, 640),
)
self.rec_model.prepare(ctx_id=-1)
self.rec_model.prepare(ctx_id=0)
def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
def _predict(self, image: np.ndarray[int, np.dtype[Any]] | bytes) -> list[dict[str, Any]]:
if isinstance(image, bytes):
image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR)
bboxes, kpss = self.det_model.detect(image)
if bboxes.size == 0:
return []
assert isinstance(kpss, np.ndarray)
assert isinstance(image, np.ndarray) and isinstance(kpss, np.ndarray)
scores = bboxes[:, 4].tolist()
bboxes = bboxes[:, :4].round().tolist()
@@ -85,3 +103,6 @@ class FaceRecognizer(InferenceModel):
@property
def cached(self) -> bool:
return self.cache_dir.is_dir() and any(self.cache_dir.glob("*.onnx"))
def configure(self, **model_kwargs: Any) -> None:
self.det_model.det_thresh = model_kwargs.pop("minScore", self.det_model.det_thresh)

View File

@@ -1,11 +1,14 @@
from io import BytesIO
from pathlib import Path
from typing import Any
from huggingface_hub import snapshot_download
from PIL.Image import Image
from transformers.pipelines import pipeline
from optimum.onnxruntime import ORTModelForImageClassification
from optimum.pipelines import pipeline
from PIL import Image
from transformers import AutoImageProcessor
from ..config import settings
from ..config import log
from ..schemas import ModelType
from .base import InferenceModel
@@ -16,27 +19,57 @@ class ImageClassifier(InferenceModel):
def __init__(
self,
model_name: str,
min_score: float = settings.min_tag_score,
min_score: float = 0.9,
cache_dir: Path | str | None = None,
**model_kwargs: Any,
) -> None:
self.min_score = min_score
self.min_score = model_kwargs.pop("minScore", min_score)
super().__init__(model_name, cache_dir, **model_kwargs)
def _download(self, **model_kwargs: Any) -> None:
snapshot_download(
cache_dir=self.cache_dir, repo_id=self.model_name, allow_patterns=["*.bin", "*.json", "*.txt"]
cache_dir=self.cache_dir,
repo_id=self.model_name,
allow_patterns=["*.bin", "*.json", "*.txt"],
local_dir=self.cache_dir,
local_dir_use_symlinks=True,
)
def _load(self, **model_kwargs: Any) -> None:
self.model = pipeline(
self.model_type.value,
self.model_name,
model_kwargs={"cache_dir": self.cache_dir, **model_kwargs},
)
processor = AutoImageProcessor.from_pretrained(self.cache_dir, cache_dir=self.cache_dir)
model_path = self.cache_dir / "model.onnx"
model_kwargs |= {
"cache_dir": self.cache_dir,
"provider": self.providers[0],
"provider_options": self.provider_options[0],
"session_options": self.sess_options,
}
def _predict(self, image: Image) -> list[str]:
if model_path.exists():
model = ORTModelForImageClassification.from_pretrained(self.cache_dir, **model_kwargs)
self.model = pipeline(self.model_type.value, model, feature_extractor=processor)
else:
log.info(
(
f"ONNX model not found in cache directory for '{self.model_name}'."
"Exporting optimized model for future use."
),
)
self.sess_options.optimized_model_filepath = model_path.as_posix()
self.model = pipeline(
self.model_type.value,
self.model_name,
model_kwargs=model_kwargs,
feature_extractor=processor,
)
def _predict(self, image: Image.Image | bytes) -> list[str]:
if isinstance(image, bytes):
image = Image.open(BytesIO(image))
predictions: list[dict[str, Any]] = self.model(image) # type: ignore
tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score]
return tags
def configure(self, **model_kwargs: Any) -> None:
self.min_score = model_kwargs.pop("minScore", self.min_score)

View File

@@ -1,4 +1,4 @@
from enum import Enum
from enum import StrEnum
from pydantic import BaseModel
@@ -20,18 +20,6 @@ class MessageResponse(BaseModel):
message: str
class TagResponse(BaseModel):
__root__: list[str]
class Embedding(BaseModel):
__root__: list[float]
class EmbeddingResponse(BaseModel):
__root__: Embedding
class BoundingBox(BaseModel):
x1: int
y1: int
@@ -39,23 +27,7 @@ class BoundingBox(BaseModel):
y2: int
class Face(BaseModel):
image_width: int
image_height: int
bounding_box: BoundingBox
score: float
embedding: Embedding
class Config:
alias_generator = to_lower_camel
allow_population_by_field_name = True
class FaceResponse(BaseModel):
__root__: list[Face]
class ModelType(Enum):
class ModelType(StrEnum):
IMAGE_CLASSIFICATION = "image-classification"
CLIP = "clip"
FACIAL_RECOGNITION = "facial-recognition"

View File

@@ -1,17 +1,20 @@
import pickle
from io import BytesIO
from typing import TypeAlias
from unittest import mock
import cv2
import numpy as np
import onnxruntime as ort
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from pytest_mock import MockerFixture
from .config import settings
from .models.base import PicklableSessionOptions
from .models.cache import ModelCache
from .models.clip import CLIPSTEncoder
from .models.clip import CLIPEncoder
from .models.facial_recognition import FaceRecognizer
from .models.image_classification import ImageClassifier
from .schemas import ModelType
@@ -72,45 +75,47 @@ class TestCLIP:
embedding = np.random.rand(512).astype(np.float32)
def test_eager_init(self, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPSTEncoder, "download")
mock_load = mocker.patch.object(CLIPSTEncoder, "load")
clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=True, test_arg="test_arg")
mocker.patch.object(CLIPEncoder, "download")
mock_load = mocker.patch.object(CLIPEncoder, "load")
clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=True, test_arg="test_arg")
assert clip_model.model_name == "test_model_name"
assert clip_model.model_name == "ViT-B-32::openai"
mock_load.assert_called_once_with(test_arg="test_arg")
def test_lazy_init(self, mocker: MockerFixture) -> None:
mock_download = mocker.patch.object(CLIPSTEncoder, "download")
mock_load = mocker.patch.object(CLIPSTEncoder, "load")
clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=False, test_arg="test_arg")
mock_download = mocker.patch.object(CLIPEncoder, "download")
mock_load = mocker.patch.object(CLIPEncoder, "load")
clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=False, test_arg="test_arg")
assert clip_model.model_name == "test_model_name"
assert clip_model.model_name == "ViT-B-32::openai"
mock_download.assert_called_once_with(test_arg="test_arg")
mock_load.assert_not_called()
def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPSTEncoder, "load")
clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
clip_encoder.model = mock.Mock()
clip_encoder.model.encode.return_value = self.embedding
mocker.patch.object(CLIPEncoder, "download")
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
mocked.return_value.run.return_value = [[self.embedding]]
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
assert clip_encoder.mode == "vision"
embedding = clip_encoder.predict(pil_image)
assert isinstance(embedding, list)
assert len(embedding) == 512
assert all([isinstance(num, float) for num in embedding])
clip_encoder.model.encode.assert_called_once()
clip_encoder.vision_model.run.assert_called_once()
def test_basic_text(self, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPSTEncoder, "load")
clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
clip_encoder.model = mock.Mock()
clip_encoder.model.encode.return_value = self.embedding
mocker.patch.object(CLIPEncoder, "download")
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
mocked.return_value.run.return_value = [[self.embedding]]
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
assert clip_encoder.mode == "text"
embedding = clip_encoder.predict("test search query")
assert isinstance(embedding, list)
assert len(embedding) == 512
assert all([isinstance(num, float) for num in embedding])
clip_encoder.model.encode.assert_called_once()
clip_encoder.text_model.run.assert_called_once()
class TestFaceRecognition:
@@ -254,3 +259,13 @@ class TestEndpoints:
headers=headers,
)
assert response.status_code == 200
def test_sess_options() -> None:
sess_options = PicklableSessionOptions()
sess_options.intra_op_num_threads = 1
sess_options.inter_op_num_threads = 1
pickled = pickle.dumps(sess_options)
unpickled = pickle.loads(pickled)
assert unpickled.intra_op_num_threads == 1
assert unpickled.inter_op_num_threads == 1

View File

@@ -1,24 +0,0 @@
export MACHINE_LEARNING_CACHE_FOLDER=/tmp/model_cache
export MACHINE_LEARNING_MIN_FACE_SCORE=0.034 # returns 1 face per request; setting this to 0 blows up the number of faces to the thousands
export MACHINE_LEARNING_MIN_TAG_SCORE=0.0
export PID_FILE=/tmp/locust_pid
export LOG_FILE=/tmp/gunicorn.log
export HEADLESS=false
export HOST=127.0.0.1:3003
export CONCURRENCY=4
export NUM_ENDPOINTS=3
export PYTHONPATH=app
gunicorn app.main:app --worker-class uvicorn.workers.UvicornWorker \
--bind $HOST --daemon --error-logfile $LOG_FILE --pid $PID_FILE
while true ; do
echo "Loading models..."
sleep 5
if cat $LOG_FILE | grep -q -E "startup complete"; then break; fi
done
# "users" are assigned only one task, so multiply concurrency by the number of tasks
locust --host http://$HOST --web-host 127.0.0.1 \
--run-time 120s --users $(($CONCURRENCY * $NUM_ENDPOINTS)) $(if $HEADLESS; then echo "--headless"; fi)
if [[ -e $PID_FILE ]]; then kill $(cat $PID_FILE); fi

View File

@@ -1,13 +1,32 @@
from io import BytesIO
import json
from typing import Any
from locust import HttpUser, events, task
from locust.env import Environment
from PIL import Image
from argparse import ArgumentParser
byte_image = BytesIO()
@events.init_command_line_parser.add_listener
def _(parser: ArgumentParser) -> None:
parser.add_argument("--tag-model", type=str, default="microsoft/resnet-50")
parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai")
parser.add_argument("--face-model", type=str, default="buffalo_l")
parser.add_argument("--tag-min-score", type=int, default=0.0,
help="Returns all tags at or above this score. The default returns all tags.")
parser.add_argument("--face-min-score", type=int, default=0.034,
help=("Returns all faces at or above this score. The default returns 1 face per request; "
"setting this to 0 blows up the number of faces to the thousands."))
parser.add_argument("--image-size", type=int, default=1000)
@events.test_start.add_listener
def on_test_start(environment, **kwargs):
def on_test_start(environment: Environment, **kwargs: Any) -> None:
global byte_image
image = Image.new("RGB", (1000, 1000))
assert environment.parsed_options is not None
image = Image.new("RGB", (environment.parsed_options.image_size, environment.parsed_options.image_size))
byte_image = BytesIO()
image.save(byte_image, format="jpeg")
@@ -19,34 +38,55 @@ class InferenceLoadTest(HttpUser):
headers: dict[str, str] = {"Content-Type": "image/jpg"}
# re-use the image across all instances in a process
def on_start(self):
def on_start(self) -> None:
global byte_image
self.data = byte_image.getvalue()
class ClassificationLoadTest(InferenceLoadTest):
class ClassificationFormDataLoadTest(InferenceLoadTest):
@task
def classify(self):
self.client.post(
"/image-classifier/tag-image", data=self.data, headers=self.headers
)
def classify(self) -> None:
data = [
("modelName", self.environment.parsed_options.clip_model),
("modelType", "clip"),
("options", json.dumps({"minScore": self.environment.parsed_options.tag_min_score})),
]
files = {"image": self.data}
self.client.post("/predict", data=data, files=files)
class CLIPLoadTest(InferenceLoadTest):
class CLIPTextFormDataLoadTest(InferenceLoadTest):
@task
def encode_image(self):
self.client.post(
"/sentence-transformer/encode-image",
data=self.data,
headers=self.headers,
)
def encode_text(self) -> None:
data = [
("modelName", self.environment.parsed_options.clip_model),
("modelType", "clip"),
("options", json.dumps({"mode": "text"})),
("text", "test search query")
]
self.client.post("/predict", data=data)
class RecognitionLoadTest(InferenceLoadTest):
class CLIPVisionFormDataLoadTest(InferenceLoadTest):
@task
def recognize(self):
self.client.post(
"/facial-recognition/detect-faces",
data=self.data,
headers=self.headers,
)
def encode_image(self) -> None:
data = [
("modelName", self.environment.parsed_options.clip_model),
("modelType", "clip"),
("options", json.dumps({"mode": "vision"})),
]
files = {"image": self.data}
self.client.post("/predict", data=data, files=files)
class RecognitionFormDataLoadTest(InferenceLoadTest):
@task
def recognize(self) -> None:
data = [
("modelName", self.environment.parsed_options.face_model),
("modelType", "facial-recognition"),
("options", json.dumps({"minScore": self.environment.parsed_options.face_min_score})),
]
files = {"image": self.data}
self.client.post("/predict", data=data, files=files)

View File

@@ -0,0 +1,17 @@
{
"version": 1,
"disable_existing_loggers": true,
"formatters": { "rich": { "show_path": false, "omit_repeated_times": false } },
"handlers": {
"console": {
"class": "app.config.CustomRichHandler",
"formatter": "rich",
"level": "INFO"
}
},
"loggers": {
"gunicorn.access": { "propagate": true },
"gunicorn.error": { "propagate": true }
},
"root": { "handlers": ["console"] }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.74.0"
version = "1.77.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@@ -13,7 +13,6 @@ torch = [
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
]
transformers = "^4.29.2"
sentence-transformers = "^2.2.2"
onnxruntime = "^1.15.0"
insightface = "^0.7.3"
opencv-python-headless = "^4.7.0.72"
@@ -22,13 +21,25 @@ fastapi = "^0.95.2"
uvicorn = {extras = ["standard"], version = "^0.22.0"}
pydantic = "^1.10.8"
aiocache = "^0.12.1"
optimum = "^1.9.1"
torchvision = [
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=0.15.2", source = "pypi"},
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=0.15.2", source = "pytorch-cpu"}
]
rich = "^13.4.2"
ftfy = "^6.1.1"
setuptools = "^68.0.0"
open-clip-torch = "^2.20.0"
python-multipart = "^0.0.6"
orjson = "^3.9.5"
safetensors = "0.3.2"
gunicorn = "^21.1.0"
[tool.poetry.group.dev.dependencies]
mypy = "^1.3.0"
black = "^23.3.0"
pytest = "^7.3.1"
locust = "^2.15.1"
gunicorn = "^20.1.0"
httpx = "^0.24.1"
pytest-asyncio = "^0.21.0"
pytest-cov = "^4.1.0"
@@ -62,13 +73,21 @@ warn_untyped_fields = true
[[tool.mypy.overrides]]
module = [
"huggingface_hub",
"transformers.pipelines",
"transformers",
"gunicorn",
"cv2",
"insightface.model_zoo",
"insightface.utils.face_align",
"insightface.utils.storage",
"sentence_transformers",
"sentence_transformers.util",
"onnxruntime",
"optimum",
"optimum.pipelines",
"optimum.onnxruntime",
"clip_server.model.clip",
"clip_server.model.clip_onnx",
"clip_server.model.pretrained_models",
"clip_server.model.tokenization",
"torchvision.transforms",
"aiocache.backends.memory",
"aiocache.lock",
"aiocache.plugins"

View File

@@ -0,0 +1,2 @@
# requirements to be installed with `--no-deps` flag
clip-server==0.8.*

13
machine-learning/start.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env sh
export LD_PRELOAD="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
: "${MACHINE_LEARNING_HOST:=0.0.0.0}"
: "${MACHINE_LEARNING_PORT:=3003}"
: "${MACHINE_LEARNING_WORKERS:=1}"
gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
-w $MACHINE_LEARNING_WORKERS \
-b $MACHINE_LEARNING_HOST:$MACHINE_LEARNING_PORT \
--log-config-json log_conf.json

View File

@@ -96,3 +96,8 @@ dependencies {
implementation "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
}
// This is uncommented in F-Droid build script
//f configurations.all {
//f exclude group: 'com.google.android.gms'
//f }

View File

@@ -55,7 +55,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
@@ -64,6 +63,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<queries>
<intent>

View File

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

View File

@@ -10,12 +10,12 @@
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.877631">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.585931">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="23.895222">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="24.755096">
</testcase>

View File

@@ -193,6 +193,8 @@
"login_form_save_login": "Stay logged in",
"login_form_server_empty": "Enter a server URL.",
"login_form_server_error": "Could not connect to server.",
"login_password_changed_success": "Password updated successfully",
"login_password_changed_error": "There was an error updating your password",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "Motion Photos",
"notification_permission_dialog_cancel": "Cancel",
@@ -301,5 +303,20 @@
"version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"translated_text_options": "Options"
"translated_text_options": "Options",
"map_no_assets_in_bounds": "No photos in this area",
"map_zoom_to_see_photos": "Zoom out to see photos",
"map_settings_dialog_title": "Map Settings",
"map_settings_dark_mode": "Dark mode",
"map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_only_relative_range": "Date range",
"map_settings_dialog_cancel": "Cancel",
"map_settings_dialog_save": "Save",
"map_cannot_get_user_location": "Cannot get user's location",
"map_location_service_disabled_title": "Location Service disabled",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_no_location_permission_title": "Location Permission denied",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_location_dialog_cancel": "Cancel",
"map_location_dialog_yes": "Yes"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -20,6 +20,8 @@ PODS:
- 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):
- Flutter
- integration_test (0.0.1):
@@ -65,6 +67,7 @@ DEPENDENCIES:
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
@@ -104,6 +107,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_web_auth/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
@@ -143,6 +148,7 @@ SPEC CHECKSUMS:
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
@@ -163,4 +169,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1

View File

@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 113;
CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 113;
CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 113;
CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -59,11 +59,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.73.0</string>
<string>1.76.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>113</string>
<string>116</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
@@ -83,8 +83,6 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSMicrophoneUsageDescription</key>

View File

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

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000187">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.403882">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.611762">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.068392">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.937008">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.988079">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.740416">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="96.47923">
<testcase classname="fastlane.lanes" name="4: build_app" time="93.625943">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="57.517755">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="62.107671">
</testcase>

View File

@@ -63,11 +63,15 @@ Future<void> initApp() async {
FlutterError.onError = (details) {
FlutterError.presentError(details);
log.severe(details.toString(), details, details.stack);
log.severe(
'Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
details,
details.stack,
);
};
PlatformDispatcher.instance.onError = (error, stack) {
log.severe(error.toString(), error, stack);
log.severe('Catch all error: ${error.toString()} - $error', error, stack);
return true;
};
}

View File

@@ -2,14 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key});
@@ -68,24 +66,12 @@ class ArchivePage extends HookConsumerWidget {
: () async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleArchive(
selection.value.toList(),
false,
);
final assetOrAssets = selection.value.length > 1
? 'assets'
: 'asset';
ImmichToast.show(
context: context,
msg:
'Moved ${selection.value.length} $assetOrAssets to library',
gravity: ToastGravity.CENTER,
);
}
await handleArchiveAssets(
ref,
context,
selection.value.toList(),
shouldArchive: false,
);
} finally {
processing.value = false;
selectionEnabledHook.value = false;

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:latlong2/latlong.dart';
@@ -41,7 +42,10 @@ class ExifBottomSheet extends HookConsumerWidget {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {'z': '$zoomLevel', 'q': formattedDateTime},
queryParameters: {
'z': '$zoomLevel',
'q': '$latitude,$longitude($formattedDateTime)',
},
);
if (await canLaunchUrl(uri)) {
return uri;
@@ -77,65 +81,35 @@ class ExifBottomSheet extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
height: 150,
width: constraints.maxWidth,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(15)),
return MapThumbnail(
coords: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
child: FlutterMap(
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: LatLng(
height: 150,
zoom: 16.0,
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
zoom: 16.0,
onTap: (tapPosition, latLong) async {
Uri? uri = await _createCoordinatesUri();
if (uri == null) {
return;
}
debugPrint('Opening Map Uri: $uri');
launchUrl(uri);
},
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
nonRotatedChildren: [
RichAttributionWidget(
attributions: [
TextSourceAttribution(
'OpenStreetMap contributors',
onTap: () => launchUrl(
Uri.parse('https://openstreetmap.org/copyright'),
),
),
],
),
],
children: [
TileLayer(
urlTemplate:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
),
MarkerLayer(
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
],
),
],
),
],
onTap: (tapPosition, latLong) async {
Uri? uri = await _createCoordinatesUri();
if (uri == null) {
return;
}
debugPrint('Opening Map Uri: $uri');
launchUrl(uri);
},
);
},
),
@@ -281,7 +255,7 @@ class ExifBottomSheet extends HookConsumerWidget {
),
),
subtitle: Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ",
),
),
],

View File

@@ -248,9 +248,9 @@ class BackupService {
req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId;
req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String();
req.fields['fileCreatedAt'] = entity.createDateTime.toUtc().toIso8601String();
req.fields['fileModifiedAt'] =
entity.modifiedDateTime.toIso8601String();
entity.modifiedDateTime.toUtc().toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['duration'] = entity.videoDuration.toString();

View File

@@ -2,13 +2,11 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key);
@@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget {
void unfavorite() async {
try {
if (selection.value.isNotEmpty) {
await ref.watch(assetProvider.notifier).toggleFavorite(
selection.value.toList(),
false,
);
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg:
'Removed ${selection.value.length} $assetOrAssets from favorites',
gravity: ToastGravity.CENTER,
await handleFavoriteAssets(
ref,
context,
selection.value.toList(),
shouldFavorite: false,
);
}
} finally {

View File

@@ -30,6 +30,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
final void Function(ItemPosition start, ItemPosition end)?
visibleItemsListener;
final Widget? topWidget;
final bool shrinkWrap;
final bool showDragScroll;
const ImmichAssetGrid({
super.key,
@@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
this.topWidget,
this.shrinkWrap = false,
this.showDragScroll = true,
});
@override
@@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
visibleItemsListener: visibleItemsListener,
topWidget: topWidget,
heroOffset: heroOffset(),
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
),
);
}

View File

@@ -35,6 +35,8 @@ class ImmichAssetGridView extends StatefulWidget {
visibleItemsListener;
final Widget? topWidget;
final int heroOffset;
final bool shrinkWrap;
final bool showDragScroll;
const ImmichAssetGridView({
super.key,
@@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget {
this.visibleItemsListener,
this.topWidget,
this.heroOffset = 0,
this.shrinkWrap = false,
this.showDragScroll = true,
});
@override
@@ -324,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
}
Widget _buildAssetGrid() {
final useDragScrolling = widget.renderList.totalAssets >= 20;
final useDragScrolling =
widget.showDragScroll && widget.renderList.totalAssets >= 20;
void dragScrolling(bool active) {
if (active != _scrolling) {
@@ -344,6 +349,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
itemCount: widget.renderList.elements.length +
(widget.topWidget != null ? 1 : 0),
addRepaintBoundaries: true,
shrinkWrap: widget.shrinkWrap,
);
final child = useDragScrolling

View File

@@ -30,9 +30,9 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
backupState.backgroundBackup || backupState.autoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
AuthenticationState authState = ref.watch(authenticationProvider);
final user = Store.get(StoreKey.currentUser);
final user = Store.tryGet(StoreKey.currentUser);
buildProfilePhoto() {
if (authState.profileImagePath.isEmpty) {
if (authState.profileImagePath.isEmpty || user == null) {
return IconButton(
splashRadius: 25,
icon: const Icon(

View File

@@ -20,16 +20,10 @@ class ProfileDrawerHeader extends HookConsumerWidget {
final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final user = Store.get(StoreKey.currentUser);
final user = Store.tryGet(StoreKey.currentUser);
buildUserProfileImage() {
var userImage = UserCircleAvatar(
radius: 35,
size: 66,
user: user,
);
if (authState.profileImagePath.isEmpty) {
if (authState.profileImagePath.isEmpty || user == null) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
@@ -37,6 +31,12 @@ class ProfileDrawerHeader extends HookConsumerWidget {
);
}
var userImage = UserCircleAvatar(
radius: 35,
size: 66,
user: user,
);
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (authState.profileImagePath.isNotEmpty) {
return userImage;

View File

@@ -25,10 +25,9 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget {
}
void onShareAssets() {
showDialog(
context: context,
builder: (BuildContext buildContext) {
ref
.watch(shareServiceProvider)
.shareAssets(selection.value.toList())
.then((_) => Navigator.of(buildContext).pop());
return const ShareDialog();
},
barrierDismissible: false,
);
handleShareAssets(ref, context, selection.value.toList());
selectionEnabledHook.value = false;
}
@@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget {
localErrorMessage: 'home_page_favorite_err_local'.tr(),
);
if (remoteAssets.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleFavorite(remoteAssets, true);
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites',
gravity: ToastGravity.BOTTOM,
);
await handleFavoriteAssets(ref, context, remoteAssets);
}
} finally {
processing.value = false;
@@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget {
final remoteAssets = remoteOnlySelection(
localErrorMessage: 'home_page_archive_err_local'.tr(),
);
if (remoteAssets.isNotEmpty) {
await ref
.read(assetProvider.notifier)
.toggleArchive(remoteAssets, true);
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
gravity: ToastGravity.CENTER,
);
}
await handleArchiveAssets(ref, context, remoteAssets);
} finally {
processing.value = false;
selectionEnabledHook.value = false;

View File

@@ -113,7 +113,17 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Store.delete(StoreKey.accessToken),
]);
state = state.copyWith(isAuthenticated: false);
state = state.copyWith(
deviceId: "",
userId: "",
userEmail: "",
firstName: '',
lastName: '',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
isAuthenticated: false,
);
} catch (e) {
log.severe("Error logging out $e");
}

View File

@@ -2,13 +2,14 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class ChangePasswordForm extends HookConsumerWidget {
const ChangePasswordForm({Key? key}) : super(key: key);
@@ -84,14 +85,35 @@ class ChangePasswordForm extends HookConsumerWidget {
.read(manualUploadProvider.notifier)
.cancelBackup();
ref.read(backupProvider.notifier).cancelBackup();
ref.read(assetProvider.notifier).clearAllAsset();
await ref
.read(assetProvider.notifier)
.clearAllAsset();
ref.read(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
AutoRouter.of(context).navigateBack();
ImmichToast.show(
context: context,
msg: "login_password_changed_success".tr(),
toastType: ToastType.success,
gravity: ToastGravity.TOP,
);
} else {
ImmichToast.show(
context: context,
msg: "login_password_changed_error".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
}
}
},
),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => AutoRouter.of(context).navigateBack(),
label: const Text('Back'),
),
],
),
),

View File

@@ -0,0 +1,40 @@
import 'package:immich_mobile/shared/models/asset.dart';
enum MapPageEventType {
mapTap,
bottomSheetScrolled,
assetsInBoundUpdated,
zoomToAsset,
zoomToCurrentLocation,
}
class MapPageEventBase {
final MapPageEventType type;
const MapPageEventBase(this.type);
}
class MapPageOnTapEvent extends MapPageEventBase {
const MapPageOnTapEvent() : super(MapPageEventType.mapTap);
}
class MapPageAssetsInBoundUpdated extends MapPageEventBase {
List<Asset> assets;
MapPageAssetsInBoundUpdated(this.assets)
: super(MapPageEventType.assetsInBoundUpdated);
}
class MapPageBottomSheetScrolled extends MapPageEventBase {
Asset? asset;
MapPageBottomSheetScrolled(this.asset)
: super(MapPageEventType.bottomSheetScrolled);
}
class MapPageZoomToAsset extends MapPageEventBase {
Asset? asset;
MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset);
}
class MapPageZoomToLocation extends MapPageEventBase {
const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation);
}

View File

@@ -0,0 +1,45 @@
class MapState {
final bool isDarkTheme;
final bool showFavoriteOnly;
final int relativeTime;
MapState({
this.isDarkTheme = false,
this.showFavoriteOnly = false,
this.relativeTime = 0,
});
MapState copyWith({
bool? isDarkTheme,
bool? showFavoriteOnly,
int? relativeTime,
}) {
return MapState(
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
relativeTime: relativeTime ?? this.relativeTime,
);
}
@override
String toString() {
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MapState &&
other.isDarkTheme == isDarkTheme &&
other.showFavoriteOnly == showFavoriteOnly &&
other.relativeTime == relativeTime;
}
@override
int get hashCode {
return isDarkTheme.hashCode ^
showFavoriteOnly.hashCode ^
relativeTime.hashCode;
}
}

View File

@@ -0,0 +1,58 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/services/map.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:latlong2/latlong.dart';
final mapMarkersProvider =
FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async {
final service = ref.read(mapServiceProvider);
final mapState = ref.read(mapStateNotifier);
DateTime? fileCreatedAfter;
bool? isFavorite;
if (mapState.relativeTime != 0) {
fileCreatedAfter =
DateTime.now().subtract(Duration(days: mapState.relativeTime));
}
if (mapState.showFavoriteOnly) {
isFavorite = true;
}
final markers = await service.getMapMarkers(
isFavorite: isFavorite,
fileCreatedAfter: fileCreatedAfter,
);
final assetMarkerData = await Future.wait(
markers.map((e) async {
final asset = await service.getAssetForMarkerId(e.id);
bool hasInvalidCoords = e.lat < -90 || e.lat > 90;
hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180);
if (asset == null || hasInvalidCoords) return null;
return AssetMarkerData(asset, LatLng(e.lat, e.lon));
}),
);
return assetMarkerData.nonNulls.toSet();
});
class AssetMarkerData {
final LatLng point;
final Asset asset;
const AssetMarkerData(this.asset, this.point);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AssetMarkerData && other.asset.remoteId == asset.remoteId;
}
@override
int get hashCode {
return asset.remoteId.hashCode;
}
}

View File

@@ -0,0 +1,51 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class MapStateNotifier extends StateNotifier<MapState> {
MapStateNotifier(this.appSettingsProvider)
: super(
MapState(
isDarkTheme: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
showFavoriteOnly: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
relativeTime: appSettingsProvider
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
),
);
final AppSettingsService appSettingsProvider;
bool get isDarkTheme => state.isDarkTheme;
void switchTheme(bool isDarkTheme) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapThemeMode,
isDarkTheme,
);
state = state.copyWith(isDarkTheme: isDarkTheme);
}
void switchFavoriteOnly(bool isFavoriteOnly) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapShowFavoriteOnly,
appSettingsProvider,
);
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
}
void setRelativeTime(int relativeTime) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapRelativeDate,
relativeTime,
);
state = state.copyWith(relativeTime: relativeTime);
}
}
final mapStateNotifier =
StateNotifierProvider<MapStateNotifier, MapState>((ref) {
return MapStateNotifier(ref.watch(appSettingsServiceProvider));
});

View File

@@ -0,0 +1,62 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final mapServiceProvider = Provider(
(ref) => MapSerivce(
ref.read(apiServiceProvider),
ref.read(dbProvider),
),
);
class MapSerivce {
final ApiService _apiService;
final Isar _db;
final log = Logger("MapService");
MapSerivce(this._apiService, this._db);
Future<List<MapMarkerResponseDto>> getMapMarkers({
bool? isFavorite,
DateTime? fileCreatedAfter,
DateTime? fileCreatedBefore,
}) async {
try {
final markers = await _apiService.assetApi.getMapMarkers(
isFavorite: isFavorite,
fileCreatedAfter: fileCreatedAfter,
fileCreatedBefore: fileCreatedBefore,
);
return markers ?? [];
} catch (error, stack) {
log.severe("Cannot get map markers ${error.toString()}", error, stack);
return [];
}
}
Future<Asset?> getAssetForMarkerId(String remoteId) async {
try {
final assets = await _db.assets.getAllByRemoteId([remoteId]);
if (assets.isNotEmpty) return assets[0];
final dto = await _apiService.assetApi.getAssetById(remoteId);
if (dto == null) {
return null;
}
return Asset.remote(dto);
} catch (error, stack) {
log.severe(
"Cannot get asset for marker ${error.toString()}",
error,
stack,
);
return null;
}
}
}

View File

@@ -0,0 +1,144 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class AssetMarkerIcon extends StatelessWidget {
const AssetMarkerIcon({
super.key,
required this.id,
this.isDarkTheme = false,
});
final String id;
final bool isDarkTheme;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: isDarkTheme ? Colors.white : Colors.black,
secondaryColor: isDarkTheme ? Colors.black : Colors.white,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(
height: constraints.maxHeight * 0.14,
width: constraints.maxWidth * 0.14,
),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: isDarkTheme ? Colors.white : Colors.black,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
cacheKey: cacheKey,
headers: {
"Authorization":
"Bearer ${Store.get(StoreKey.accessToken)}",
},
errorListener: () =>
const Icon(Icons.image_not_supported_outlined),
),
),
),
),
],
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
_PinPainter({
this.primaryColor = Colors.black,
this.secondaryColor = Colors.white,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(
Offset(size.width / 2, size.height),
primaryRadius,
primaryBrush,
);
canvas.drawCircle(
Offset(size.width / 2, size.height),
secondaryRadius,
secondaryBrush,
);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(
Offset(size.width / 2, 0),
Offset(
size.width / 2,
size.height,
),
lineBrush,
);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(
controlPoint.dx,
controlPoint.dy,
firstEndPoint.dx,
firstEndPoint.dy,
)
..quadraticBezierTo(
controlPoint.dx,
controlPoint.dy,
secondEndPoint.dx,
secondEndPoint.dy,
)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor ||
old.secondaryColor != secondaryColor;
}
}

View File

@@ -0,0 +1,30 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
class LocationServiceDisabledDialog extends ConfirmDialog {
LocationServiceDisabledDialog({Key? key})
: super(
key: key,
title: 'map_location_service_disabled_title'.tr(),
content: 'map_location_service_disabled_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () async {
await Geolocator.openLocationSettings();
},
);
}
class LocationPermissionDisabledDialog extends ConfirmDialog {
LocationPermissionDisabledDialog({Key? key})
: super(
key: key,
title: 'map_no_location_permission_title'.tr(),
content: 'map_no_location_permission_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () {},
);
}

View File

@@ -0,0 +1,138 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
class MapAppBar extends HookWidget implements PreferredSizeWidget {
final ValueNotifier<bool> selectionEnabled;
final int selectedAssetsLength;
final bool isDarkTheme;
final void Function() onShare;
final void Function() onFavorite;
final void Function() onArchive;
const MapAppBar({
super.key,
required this.selectionEnabled,
required this.selectedAssetsLength,
required this.onShare,
required this.onArchive,
required this.onFavorite,
this.isDarkTheme = false,
});
List<Widget> buildNonSelectionWidgets(BuildContext context) {
return [
Padding(
padding: const EdgeInsets.only(left: 15, top: 15),
child: ElevatedButton(
onPressed: () => AutoRouter.of(context).pop(),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22),
),
),
Padding(
padding: const EdgeInsets.only(right: 15, top: 15),
child: ElevatedButton(
onPressed: () => showDialog(
context: context,
builder: (BuildContext _) {
return const MapSettingsDialog();
},
),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(Icons.more_vert_rounded, size: 22),
),
),
];
}
List<Widget> buildSelectionWidgets() {
return [
DisableMultiSelectButton(
onPressed: () {
selectionEnabled.value = false;
},
selectedItemCount: selectedAssetsLength,
),
Row(
children: [
// Share button
Padding(
padding: const EdgeInsets.only(top: 15),
child: ElevatedButton(
onPressed: onShare,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: Icon(
Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
size: 22,
),
),
),
// Favorite button
Padding(
padding: const EdgeInsets.only(top: 15),
child: ElevatedButton(
onPressed: onFavorite,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.favorite,
size: 22,
),
),
),
// Archive Button
Padding(
padding: const EdgeInsets.only(right: 10, top: 15),
child: ElevatedButton(
onPressed: onArchive,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.archive,
size: 22,
),
),
),
],
),
];
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
if (selectionEnabled.value) ...buildSelectionWidgets(),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}

View File

@@ -0,0 +1,368 @@
import 'dart:async';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:url_launcher/url_launcher.dart';
class MapPageBottomSheet extends StatefulHookConsumerWidget {
final Stream mapPageEventStream;
final StreamController bottomSheetEventSC;
final bool selectionEnabled;
final ImmichAssetGridSelectionListener selectionlistener;
final bool isDarkTheme;
const MapPageBottomSheet({
super.key,
required this.mapPageEventStream,
required this.bottomSheetEventSC,
required this.selectionEnabled,
required this.selectionlistener,
this.isDarkTheme = false,
});
@override
AssetsInBoundBottomSheetState createState() =>
AssetsInBoundBottomSheetState();
}
class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
// Non-State variables
bool userTappedOnMap = false;
RenderList? _cachedRenderList;
int assetOffsetInSheet = -1;
late final DraggableScrollableController bottomSheetController;
late final Debounce debounce;
@override
void initState() {
super.initState();
bottomSheetController = DraggableScrollableController();
debounce = Debounce(
const Duration(milliseconds: 100),
);
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final bottomPadding =
Platform.isAndroid ? MediaQuery.of(context).padding.bottom - 10 : 0.0;
final maxHeight = MediaQuery.of(context).size.height - bottomPadding;
final isSheetScrolled = useState(false);
final isSheetExpanded = useState(false);
final assetsInBound = useState(<Asset>[]);
final currentExtend = useState(0.1);
void handleMapPageEvents(dynamic event) {
if (event is MapPageAssetsInBoundUpdated) {
assetsInBound.value = event.assets;
} else if (event is MapPageOnTapEvent) {
userTappedOnMap = true;
assetOffsetInSheet = -1;
bottomSheetController.animateTo(
0.1,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
);
isSheetScrolled.value = false;
}
}
useEffect(
() {
final mapPageEventSubscription =
widget.mapPageEventStream.listen(handleMapPageEvents);
return mapPageEventSubscription.cancel;
},
[widget.mapPageEventStream],
);
void handleVisibleItems(ItemPosition start, ItemPosition end) {
final renderElement = _cachedRenderList?.elements[start.index];
if (renderElement == null) {
return;
}
final rowOffset = renderElement.offset;
if ((-start.itemLeadingEdge) != 0) {
var columnOffset = -start.itemLeadingEdge ~/ 0.05;
columnOffset = columnOffset < renderElement.totalCount
? columnOffset
: renderElement.totalCount - 1;
assetOffsetInSheet = rowOffset + columnOffset;
final asset = _cachedRenderList?.allAssets?[assetOffsetInSheet];
userTappedOnMap = false;
if (!userTappedOnMap && isSheetExpanded.value) {
widget.bottomSheetEventSC.add(
MapPageBottomSheetScrolled(asset),
);
}
if (isSheetExpanded.value) {
isSheetScrolled.value = true;
}
}
}
void visibleItemsListener(ItemPosition start, ItemPosition end) {
if (_cachedRenderList == null) {
debounce.dispose();
return;
}
debounce.call(() => handleVisibleItems(start, end));
}
Widget buildNoPhotosWidget() {
const image = Image(
image: AssetImage('assets/lighthouse.png'),
);
return isSheetExpanded.value
? Column(
children: [
const SizedBox(
height: 80,
),
SizedBox(
height: 150,
width: 150,
child: isDarkMode
? const InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: BrightnessFilter(
brightness: -5,
child: image,
),
),
)
: image,
),
const SizedBox(
height: 20,
),
Text(
"map_zoom_to_see_photos".tr(),
style: TextStyle(
fontSize: 20,
color: Theme.of(context).textTheme.displayLarge?.color,
),
),
],
)
: const SizedBox.shrink();
}
void onTapMapButton() {
if (assetOffsetInSheet != -1) {
widget.bottomSheetEventSC.add(
MapPageZoomToAsset(
_cachedRenderList?.allAssets?[assetOffsetInSheet],
),
);
}
}
Widget buildDragHandle(ScrollController scrollController) {
final textToDisplay = assetsInBound.value.isNotEmpty
? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}"
: "map_no_assets_in_bounds".tr();
final dragHandle = Container(
height: 60,
width: double.infinity,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 5),
const CustomDraggingHandle(),
const SizedBox(height: 15),
Text(
textToDisplay,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).textTheme.displayLarge?.color,
fontWeight: FontWeight.bold,
),
),
Divider(
height: 10,
color: Theme.of(context)
.textTheme
.displayLarge
?.color
?.withOpacity(0.5),
),
],
),
if (isSheetExpanded.value && isSheetScrolled.value)
Positioned(
top: 5,
right: 10,
child: IconButton(
icon: Icon(
Icons.map_outlined,
color: Theme.of(context).textTheme.displayLarge?.color,
),
iconSize: 20,
tooltip: 'Zoom to bounds',
onPressed: onTapMapButton,
),
),
],
),
);
return SingleChildScrollView(
controller: scrollController,
physics: const ClampingScrollPhysics(),
child: dragHandle,
);
}
return NotificationListener<DraggableScrollableNotification>(
onNotification: (DraggableScrollableNotification notification) {
final sheetExtended = notification.extent > 0.2;
isSheetExpanded.value = sheetExtended;
currentExtend.value = notification.extent;
if (!sheetExtended) {
// reset state
userTappedOnMap = false;
assetOffsetInSheet = -1;
isSheetScrolled.value = false;
}
return true;
},
child: Padding(
padding: EdgeInsets.only(
bottom: bottomPadding,
),
child: Stack(
children: [
DraggableScrollableSheet(
controller: bottomSheetController,
initialChildSize: 0.1,
minChildSize: 0.1,
maxChildSize: 0.55,
snap: true,
builder: (
BuildContext context,
ScrollController scrollController,
) {
return Card(
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
surfaceTintColor: Colors.transparent,
elevation: 18.0,
margin: const EdgeInsets.all(0),
child: Column(
children: [
buildDragHandle(scrollController),
if (isSheetExpanded.value &&
assetsInBound.value.isNotEmpty)
ref
.watch(
renderListProvider(
assetsInBound.value,
),
)
.when(
data: (renderList) {
_cachedRenderList = renderList;
final assetGrid = ImmichAssetGrid(
shrinkWrap: true,
renderList: renderList,
showDragScroll: false,
selectionActive: widget.selectionEnabled,
showMultiSelectIndicator: false,
listener: widget.selectionlistener,
visibleItemsListener: visibleItemsListener,
);
return Expanded(child: assetGrid);
},
error: (error, stackTrace) {
log.warning(
"Cannot get assets in the current map bounds ${error.toString()}",
error,
stackTrace,
);
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
),
if (isSheetExpanded.value && assetsInBound.value.isEmpty)
Expanded(
child: SingleChildScrollView(
child: buildNoPhotosWidget(),
),
),
],
),
);
},
),
Positioned(
bottom: maxHeight * currentExtend.value,
left: 0,
child: GestureDetector(
onTap: () => launchUrl(
Uri.parse('https://openstreetmap.org/copyright'),
),
child: ColoredBox(
color: (widget.isDarkTheme
? Colors.grey[900]
: Colors.grey[100])!,
child: Padding(
padding: const EdgeInsets.all(3),
child: Text(
'© OpenStreetMap contributors',
style: TextStyle(
fontSize: 6,
color: !widget.isDarkTheme
? Colors.grey[900]
: Colors.grey[100],
),
),
),
),
),
),
Positioned(
bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
right: 15,
child: ElevatedButton(
onPressed: () => widget.bottomSheetEventSC
.add(const MapPageZoomToLocation()),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.my_location,
size: 22,
fill: 1,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,193 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
class MapSettingsDialog extends HookConsumerWidget {
const MapSettingsDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapSettingsNotifier = ref.read(mapStateNotifier.notifier);
final mapSettings = ref.read(mapStateNotifier);
final isDarkMode = useState(mapSettings.isDarkTheme);
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
final showRelativeDate = useState(mapSettings.relativeTime);
final ThemeData theme = Theme.of(context);
Widget buildMapThemeSetting() {
return SwitchListTile.adaptive(
value: isDarkMode.value,
onChanged: (value) {
isDarkMode.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_dark_mode".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildFavoriteOnlySetting() {
return SwitchListTile.adaptive(
value: showFavoriteOnly.value,
onChanged: (value) {
showFavoriteOnly.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_only_show_favorites".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildDateRangeSetting() {
final now = DateTime.now();
return DropdownMenu(
enableSearch: false,
enableFilter: false,
initialSelection: showRelativeDate.value,
onSelected: (value) {
showRelativeDate.value = value!;
},
dropdownMenuEntries: [
const DropdownMenuEntry(value: 0, label: "All"),
const DropdownMenuEntry(
value: 1,
label: "Past 24 hours",
),
const DropdownMenuEntry(
value: 7,
label: "Past 7 days",
),
const DropdownMenuEntry(
value: 30,
label: "Past 30 days",
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 1,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "Past year",
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 3,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "Past 3 years",
),
],
);
}
List<Widget> getDialogActions() {
return <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
backgroundColor:
mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
),
child: Text(
"map_settings_dialog_cancel".tr(),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color:
mapSettings.isDarkTheme ? Colors.grey[900] : Colors.grey[100],
),
),
),
TextButton(
onPressed: () {
mapSettingsNotifier.switchTheme(isDarkMode.value);
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
backgroundColor: theme.primaryColor,
),
child: Text(
"map_settings_dialog_save".tr(),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.primaryTextTheme.labelLarge?.color,
),
),
),
];
}
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Center(
child: Text(
"map_settings_dialog_title".tr(),
style: TextStyle(
color: theme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: ListView(
shrinkWrap: true,
children: [
buildMapThemeSetting(),
buildFavoriteOnlySetting(),
const SizedBox(
height: 10,
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"map_settings_only_relative_range".tr(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
buildDateRangeSetting(),
],
),
),
].toList(),
),
),
),
actions: getDialogActions(),
actionsAlignment: MainAxisAlignment.spaceEvenly,
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
// A non-interactive thumbnail of a map in the given coordinates with optional markers
class MapThumbnail extends HookConsumerWidget {
final Function(TapPosition, LatLng)? onTap;
final LatLng coords;
final double zoom;
final List<Marker> markers;
final double height;
final bool showAttribution;
final bool isDarkTheme;
const MapThumbnail({
super.key,
required this.coords,
required this.height,
this.onTap,
this.zoom = 1,
this.showAttribution = true,
this.isDarkTheme = false,
this.markers = const [],
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tileLayer = TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
);
return SizedBox(
height: height,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: FlutterMap(
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: coords,
zoom: zoom,
onTap: onTap,
),
nonRotatedChildren: [
if (showAttribution)
RichAttributionWidget(
animationConfig: const ScaleRAWA(),
attributions: [
TextSourceAttribution(
'OpenStreetMap contributors',
onTap: () => launchUrl(
Uri.parse('https://openstreetmap.org/copyright'),
),
),
],
),
],
children: [
isDarkTheme
? InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: tileLayer,
),
)
: tileLayer,
if (markers.isNotEmpty) MarkerLayer(markers: markers),
],
),
),
);
}
}

View File

@@ -0,0 +1,508 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:flutter_map_heatmap/flutter_map_heatmap.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart';
import 'package:immich_mobile/modules/map/ui/location_dialog.dart';
import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/flutter_map_extensions.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:latlong2/latlong.dart';
import 'package:logging/logging.dart';
class MapPage extends StatefulHookConsumerWidget {
const MapPage({super.key});
@override
MapPageState createState() => MapPageState();
}
class MapPageState extends ConsumerState<MapPage> {
// Non-State variables
late final MapController mapController;
// Streams are used instead of callbacks to prevent unnecessary rebuilds on events
final StreamController mapPageEventSC =
StreamController<MapPageEventBase>.broadcast();
final StreamController bottomSheetEventSC =
StreamController<MapPageEventBase>.broadcast();
late final Stream bottomSheetEventStream;
// Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet
// resulting in it getting reloaded each time a map move occurs
Set<AssetMarkerData> assetsInBounds = {};
// TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded
// https://github.com/fleaflet/flutter_map/issues/1542
// The below is used instead of MapEventMove#id to handle event from controller
// in onMapEvent() since MapEventMove#id is not populated properly in the
// current version of flutter_map(4.0.0) used
bool forceAssetUpdate = false;
late final Debounce debounce;
@override
void initState() {
super.initState();
mapController = MapController();
bottomSheetEventStream = bottomSheetEventSC.stream;
// Map zoom events and move events are triggered often. Throttle the call to limit rebuilds
debounce = Debounce(
const Duration(milliseconds: 300),
);
}
@override
void dispose() {
debounce.dispose();
super.dispose();
}
void reloadAssetsInBound(
Set<AssetMarkerData>? assetMarkers, {
bool forceReload = false,
}) {
final bounds = mapController.bounds;
if (bounds != null) {
final oldAssetsInBounds = assetsInBounds.toSet();
assetsInBounds =
assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
final shouldReload = forceReload ||
assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
assetsInBounds.length != oldAssetsInBounds.length;
if (shouldReload) {
mapPageEventSC.add(
MapPageAssetsInBoundUpdated(
assetsInBounds.map((e) => e.asset).toList(),
),
);
}
}
}
void openAssetInViewer(Asset asset) {
AutoRouter.of(context).push(
GalleryViewerRoute(
initialIndex: 0,
loadAsset: (index) => asset,
totalAssets: 1,
heroOffset: 0,
),
);
}
@override
Widget build(BuildContext context) {
final log = Logger("MapService");
final isDarkTheme =
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
final ValueNotifier<Set<AssetMarkerData>> mapMarkerData =
useState(<AssetMarkerData>{});
final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null);
final selectionEnabledHook = useState(false);
final selectedAssets = useState(<Asset>{});
final showLoadingIndicator = useState(false);
final refetchMarkers = useState(true);
if (refetchMarkers.value) {
mapMarkerData.value = ref.watch(mapMarkersProvider).when(
skipLoadingOnRefresh: false,
error: (error, stackTrace) {
log.warning(
"Cannot get map markers ${error.toString()}",
error,
stackTrace,
);
showLoadingIndicator.value = false;
return {};
},
loading: () {
showLoadingIndicator.value = true;
return {};
},
data: (data) {
showLoadingIndicator.value = false;
refetchMarkers.value = false;
closestAssetMarker.value = null;
debounce(
() => reloadAssetsInBound(
mapMarkerData.value,
forceReload: true,
),
);
return data;
},
);
}
ref.listen(mapStateNotifier, (previous, next) {
bool shouldRefetch =
previous?.showFavoriteOnly != next.showFavoriteOnly ||
previous?.relativeTime != next.relativeTime;
if (shouldRefetch) {
refetchMarkers.value = shouldRefetch;
ref.invalidate(mapMarkersProvider);
}
});
void onZoomToAssetEvent(Asset? assetInBottomSheet) {
if (assetInBottomSheet != null) {
final mapMarker = mapMarkerData.value
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
if (mapMarker != null) {
const zoomLevel = 16.0;
LatLng? newCenter = mapController.centerBoundsWithPadding(
mapMarker.point,
const Offset(0, -120),
zoomLevel: zoomLevel,
);
if (newCenter != null) {
forceAssetUpdate = true;
mapController.move(newCenter, zoomLevel);
}
}
}
}
void onZoomToLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
showDialog(
context: context,
builder: (context) => Theme(
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: LocationServiceDisabledDialog(),
),
);
return;
}
LocationPermission permission = await Geolocator.checkPermission();
bool shouldRequestPermission = false;
if (permission == LocationPermission.denied) {
shouldRequestPermission = await showDialog(
context: context,
builder: (context) => Theme(
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: LocationPermissionDisabledDialog(),
),
);
if (shouldRequestPermission) {
permission = await Geolocator.requestPermission();
}
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
// Open app settings only if you did not request for permission before
if (permission == LocationPermission.deniedForever &&
!shouldRequestPermission) {
await Geolocator.openAppSettings();
}
return;
}
Position currentUserLocation = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 5),
);
forceAssetUpdate = true;
mapController.move(
LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
12,
);
} catch (error) {
log.severe(
"Cannot get user's current location due to ${error.toString()}",
);
if (context.mounted) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: "map_cannot_get_user_location".tr(),
);
}
}
}
void handleBottomSheetEvents(dynamic event) {
if (event is MapPageBottomSheetScrolled) {
final assetInBottomSheet = event.asset;
if (assetInBottomSheet != null) {
final mapMarker = mapMarkerData.value
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
closestAssetMarker.value = mapMarker;
if (mapMarker != null && mapController.zoom >= 5) {
LatLng? newCenter = mapController.centerBoundsWithPadding(
mapMarker.point,
const Offset(0, -120),
);
if (newCenter != null) {
mapController.move(
newCenter,
mapController.zoom,
);
}
}
}
} else if (event is MapPageZoomToAsset) {
onZoomToAssetEvent(event.asset);
} else if (event is MapPageZoomToLocation) {
onZoomToLocation();
}
}
useEffect(
() {
final bottomSheetEventSubscription =
bottomSheetEventStream.listen(handleBottomSheetEvents);
return bottomSheetEventSubscription.cancel;
},
[bottomSheetEventStream],
);
void handleMapTapEvent(LatLng tapPosition) {
const d = Distance();
final assetsInBoundsList = assetsInBounds.toList();
assetsInBoundsList.sort(
(a, b) => d
.distance(a.point, tapPosition)
.compareTo(d.distance(b.point, tapPosition)),
);
// First asset less than the threshold from the tap point
final nearestAsset = assetsInBoundsList.firstWhereOrNull(
(element) =>
d.distance(element.point, tapPosition) <
mapController.getTapThresholdForZoomLevel(),
);
// Reset marker if no assets are near the tap point
if (nearestAsset == null && closestAssetMarker.value != null) {
selectionEnabledHook.value = false;
mapPageEventSC.add(
const MapPageOnTapEvent(),
);
}
closestAssetMarker.value = nearestAsset;
}
void onMapEvent(MapEvent mapEvent) {
if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) {
if (forceAssetUpdate ||
mapEvent.source != MapEventSource.mapController) {
debounce(() {
if (selectionEnabledHook.value) {
selectionEnabledHook.value = false;
}
reloadAssetsInBound(
mapMarkerData.value,
forceReload: forceAssetUpdate,
);
forceAssetUpdate = false;
});
}
} else if (mapEvent is MapEventTap) {
handleMapTapEvent(mapEvent.tapPosition);
}
}
void onShareAsset() {
handleShareAssets(ref, context, selectedAssets.value.toList());
selectionEnabledHook.value = false;
}
void onFavoriteAsset() async {
showLoadingIndicator.value = true;
try {
await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
} finally {
showLoadingIndicator.value = false;
selectionEnabledHook.value = false;
refetchMarkers.value = true;
}
}
void onArchiveAsset() async {
showLoadingIndicator.value = true;
try {
await handleArchiveAssets(ref, context, selectedAssets.value.toList());
} finally {
showLoadingIndicator.value = false;
selectionEnabledHook.value = false;
refetchMarkers.value = true;
}
}
void selectionListener(bool isMultiSelect, Set<Asset> selection) {
selectionEnabledHook.value = isMultiSelect;
selectedAssets.value = selection;
}
final tileLayer = TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
maxNativeZoom: 19,
maxZoom: 19,
);
final darkTileLayer = InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: BrightnessFilter(
brightness: -1,
child: tileLayer,
),
),
);
final markerLayer = MarkerLayer(
markers: [
if (closestAssetMarker.value != null)
AssetMarker(
remoteId: closestAssetMarker.value!.asset.remoteId!,
anchorPos: AnchorPos.align(AnchorAlign.top),
point: closestAssetMarker.value!.point,
width: 100,
height: 100,
builder: (ctx) => GestureDetector(
onTap: () => openAssetInViewer(closestAssetMarker.value!.asset),
child: AssetMarkerIcon(
key: Key(closestAssetMarker.value!.asset.remoteId!),
isDarkTheme: isDarkTheme,
id: closestAssetMarker.value!.asset.remoteId!,
),
),
),
],
);
final heatMapLayer = mapMarkerData.value.isNotEmpty
? HeatMapLayer(
heatMapDataSource: InMemoryHeatMapDataSource(
data: mapMarkerData.value
.map(
(e) => WeightedLatLng(
LatLng(e.point.latitude, e.point.longitude),
1,
),
)
.toList(),
),
heatMapOptions: HeatMapOptions(
radius: 60,
layerOpacity: 0.5,
gradient: {
0.20: Colors.deepPurple,
0.40: Colors.blue,
0.60: Colors.green,
0.95: Colors.yellow,
1.0: Colors.deepOrange,
},
),
)
: const SizedBox.shrink();
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor:
(isDarkTheme ? Colors.black : Colors.white).withOpacity(0.5),
statusBarIconBrightness:
isDarkTheme ? Brightness.light : Brightness.dark,
systemNavigationBarColor:
isDarkTheme ? Colors.grey[900] : Colors.grey[100],
systemNavigationBarIconBrightness:
isDarkTheme ? Brightness.light : Brightness.dark,
systemNavigationBarDividerColor: Colors.transparent,
),
child: Theme(
// Override app theme based on map theme
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: Scaffold(
appBar: MapAppBar(
isDarkTheme: isDarkTheme,
selectionEnabled: selectionEnabledHook,
selectedAssetsLength: selectedAssets.value.length,
onShare: onShareAsset,
onArchive: onArchiveAsset,
onFavorite: onFavoriteAsset,
),
extendBodyBehindAppBar: true,
body: Stack(
children: [
FlutterMap(
mapController: mapController,
options: MapOptions(
maxBounds:
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
interactiveFlags: InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom,
center: LatLng(20, 20),
zoom: 2,
minZoom: 1,
maxZoom: 18, // max level supported by OSM,
onMapReady: () {
mapController.mapEventStream.listen(onMapEvent);
},
),
children: [
isDarkTheme ? darkTileLayer : tileLayer,
heatMapLayer,
markerLayer,
],
),
MapPageBottomSheet(
mapPageEventStream: mapPageEventSC.stream,
bottomSheetEventSC: bottomSheetEventSC,
selectionEnabled: selectionEnabledHook.value,
selectionlistener: selectionListener,
isDarkTheme: isDarkTheme,
),
if (showLoadingIndicator.value)
Positioned(
top: MediaQuery.of(context).size.height * 0.35,
left: MediaQuery.of(context).size.width * 0.425,
child: const ImmichLoadingIndicator(),
),
],
),
),
),
);
}
}
class AssetMarker extends Marker {
String remoteId;
AssetMarker({
super.key,
required this.remoteId,
super.anchorPos,
required super.point,
super.width = 100.0,
super.height = 100.0,
required super.builder,
});
}

View File

@@ -0,0 +1,110 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:latlong2/latlong.dart';
class CuratedPlacesRow extends CuratedRow {
const CuratedPlacesRow({
super.key,
required super.content,
super.imageSize,
super.onTap,
});
@override
Widget build(BuildContext context) {
Widget buildMapThumbnail() {
return GestureDetector(
onTap: () => AutoRouter.of(context).push(
const MapRoute(),
),
child: SizedBox(
height: imageSize,
width: imageSize,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 10.0),
child: MapThumbnail(
zoom: 2,
coords: LatLng(
47,
5,
),
height: imageSize,
showAttribution: false,
isDarkTheme: Theme.of(context).brightness == Brightness.dark,
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.black,
gradient: LinearGradient(
begin: FractionalOffset.topCenter,
end: FractionalOffset.bottomCenter,
colors: [
Colors.blueGrey.withOpacity(0.0),
Colors.black.withOpacity(0.4),
],
stops: const [0.0, 1.0],
),
),
),
const Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 10),
child: Text(
"Your Map",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
],
),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemBuilder: (context, index) {
// Injecting Map thumbnail as the first element
if (index == 0) {
return buildMapThumbnail();
}
// The actual index is 1 less than the virutal index since we inject map into the first position
final actualIndex = index - 1;
final object = content[actualIndex];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
return SizedBox(
width: imageSize,
height: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, actualIndex),
),
),
);
},
// Adding 1 to inject map thumbnail as first element
itemCount: content.length + 1,
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -7,7 +8,7 @@ import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
import 'package:immich_mobile/modules/search/ui/curated_places_row.dart';
import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart';
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
@@ -27,7 +28,7 @@ class SearchPage extends HookConsumerWidget {
final curatedLocation = ref.watch(getCuratedLocationProvider);
final curatedPeople = ref.watch(getCuratedPeopleProvider);
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
double imageSize = MediaQuery.of(context).size.width / 3;
double imageSize = math.min(MediaQuery.of(context).size.width / 3, 150);
TextStyle categoryTitleStyle = const TextStyle(
fontWeight: FontWeight.bold,
@@ -69,7 +70,7 @@ class SearchPage extends HookConsumerWidget {
buildPeople() {
return SizedBox(
height: MediaQuery.of(context).size.width / 3,
height: imageSize,
child: curatedPeople.when(
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
@@ -105,7 +106,7 @@ class SearchPage extends HookConsumerWidget {
child: curatedLocation.when(
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (locations) => CuratedRow(
data: (locations) => CuratedPlacesRow(
content: locations
.map(
(o) => CuratedContent(
@@ -155,6 +156,7 @@ class SearchPage extends HookConsumerWidget {
),
top: 0,
),
const SizedBox(height: 10.0),
buildPlaces(),
const SizedBox(height: 24.0),
Padding(

View File

@@ -46,6 +46,9 @@ enum AppSettingsEnum<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
mapThemeMode<bool>(StoreKey.mapThemeMode, null, false),
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
;
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/map/views/map_page.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart';
@@ -153,6 +154,7 @@ part 'router.gr.dart';
),
AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
],
)

View File

@@ -296,6 +296,12 @@ class _$AppRouter extends RootStackRouter {
),
);
},
MapRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const MapPage(),
);
},
AlbumOptionsRoute.name: (routeData) {
final args = routeData.argsAs<AlbumOptionsRouteArgs>();
return MaterialPageX<dynamic>(
@@ -605,6 +611,14 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
MapRoute.name,
path: '/map-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
AlbumOptionsRoute.name,
path: '/album-options-page',
@@ -1337,6 +1351,17 @@ class MemoryRouteArgs {
}
}
/// [MapPage]
class MapRoute extends PageRouteInfo<void> {
const MapRoute()
: super(
MapRoute.name,
path: '/map-page',
);
static const String name = 'MapRoute';
}
/// generated route for
/// [AlbumOptionsPage]
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {

View File

@@ -174,6 +174,10 @@ enum StoreKey<T> {
advancedTroubleshooting<bool>(114, type: bool),
logLevel<int>(115, type: int),
preferRemoteImage<bool>(116, type: bool),
// map related settings
mapThemeMode<bool>(117, type: bool),
mapShowFavoriteOnly<bool>(118, type: bool),
mapRelativeDate<int>(119, type: int),
;
const StoreKey(

View File

@@ -69,7 +69,6 @@ class AssetService {
await _apiService.assetApi.getAllAssetsWithETag(
eTag: etag,
userId: user.id,
withoutThumbs: true,
);
if (assets == null) {
return null;

View File

@@ -26,7 +26,7 @@ class ConfirmDialog extends ConsumerWidget {
content: Text(content).tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(context).pop(false),
child: Text(
cancel,
style: TextStyle(
@@ -38,7 +38,7 @@ class ConfirmDialog extends ConsumerWidget {
TextButton(
onPressed: () {
onOk();
Navigator.of(context).pop();
Navigator.of(context).pop(true);
},
child: Text(
ok,

View File

@@ -0,0 +1,104 @@
import 'package:flutter/widgets.dart';
class InvertionFilter extends StatelessWidget {
final Widget? child;
const InvertionFilter({super.key, this.child});
@override
Widget build(BuildContext context) {
return ColorFiltered(
colorFilter: const ColorFilter.matrix(<double>[
-1, 0, 0, 0, 255, //
0, -1, 0, 0, 255, //
0, 0, -1, 0, 255, //
0, 0, 0, 1, 0, //
]),
child: child,
);
}
}
// -1 - darkest, 1 - brightest, 0 - unchanged
class BrightnessFilter extends StatelessWidget {
final Widget? child;
final double brightness;
const BrightnessFilter({super.key, this.child, this.brightness = 0});
@override
Widget build(BuildContext context) {
return ColorFiltered(
colorFilter: ColorFilter.matrix(
_ColorFilterGenerator.brightnessAdjustMatrix(brightness),
),
child: child,
);
}
}
// -1 - greyscale, 1 - most saturated, 0 - unchanged
class SaturationFilter extends StatelessWidget {
final Widget? child;
final double saturation;
const SaturationFilter({super.key, this.child, this.saturation = 0});
@override
Widget build(BuildContext context) {
return ColorFiltered(
colorFilter: ColorFilter.matrix(
_ColorFilterGenerator.saturationAdjustMatrix(saturation),
),
child: child,
);
}
}
class _ColorFilterGenerator {
static List<double> brightnessAdjustMatrix(double value) {
value = value * 10;
if (value == 0) {
return [
1, 0, 0, 0, 0, //
0, 1, 0, 0, 0, //
0, 0, 1, 0, 0, //
0, 0, 0, 1, 0, //
];
}
return List<double>.from(<double>[
1, 0, 0, 0, value, 0, 1, 0, 0, value, 0, 0, 1, 0, value, 0, 0, 0, 1, 0, //
]).map((i) => i.toDouble()).toList();
}
static List<double> saturationAdjustMatrix(double value) {
value = value * 100;
if (value == 0) {
return [
1, 0, 0, 0, 0, //
0, 1, 0, 0, 0, //
0, 0, 1, 0, 0, //
0, 0, 0, 1, 0, //
];
}
double x =
((1 + ((value > 0) ? ((3 * value) / 100) : (value / 100)))).toDouble();
double lumR = 0.3086;
double lumG = 0.6094;
double lumB = 0.082;
return List<double>.from(<double>[
(lumR * (1 - x)) + x, lumG * (1 - x), lumB * (1 - x), //
0, 0, //
lumR * (1 - x), //
(lumG * (1 - x)) + x, //
lumB * (1 - x), //
0, 0, //
lumR * (1 - x), //
lumG * (1 - x), //
(lumB * (1 - x)) + x, //
0, 0, 0, 0, 0, 1, 0, //
]).map((i) => i.toDouble()).toList();
}
}

View File

@@ -0,0 +1,26 @@
import 'dart:async';
import 'package:flutter/material.dart';
class Debounce {
Debounce(Duration interval) : _interval = interval.inMilliseconds;
final int _interval;
Timer? _timer;
VoidCallback? action;
void call(VoidCallback? action) {
this.action = action;
_timer?.cancel();
_timer = Timer(Duration(milliseconds: _interval), _callAndRest);
}
void _callAndRest() {
action?.call();
_timer = null;
}
void dispose() {
_timer?.cancel();
_timer = null;
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'dart:math' as math;
extension MoveByBounds on MapController {
// TODO: Remove this in favor of built-in method when upgrading flutter_map to 5.0.0
LatLng? centerBoundsWithPadding(
LatLng coordinates,
Offset offset, {
double? zoomLevel,
}) {
const crs = Epsg3857();
final oldCenterPt = crs.latLngToPoint(coordinates, zoomLevel ?? zoom);
final mapCenterPoint = _rotatePoint(
oldCenterPt,
oldCenterPt - CustomPoint(offset.dx, offset.dy),
);
return crs.pointToLatLng(mapCenterPoint, zoomLevel ?? zoom);
}
CustomPoint<double> _rotatePoint(
CustomPoint<double> mapCenter,
CustomPoint<double> point, {
bool counterRotation = true,
}) {
final counterRotationFactor = counterRotation ? -1 : 1;
final m = Matrix4.identity()
..translate(mapCenter.x, mapCenter.y)
..rotateZ(degToRadian(rotation) * counterRotationFactor)
..translate(-mapCenter.x, -mapCenter.y);
final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y));
return CustomPoint(tp.dx, tp.dy);
}
double getTapThresholdForZoomLevel() {
const scale = [
25000000,
15000000,
8000000,
4000000,
2000000,
1000000,
500000,
250000,
100000,
50000,
25000,
15000,
8000,
4000,
2000,
1000,
500,
250,
100,
50,
25,
10,
5,
];
return scale[math.max(0, math.min(20, zoom.round() + 2))].toDouble() / 6;
}
}

View File

@@ -7,17 +7,20 @@ String getThumbnailUrl(
final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
return _getThumbnailUrl(asset.remoteId!, type: type);
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
}
String getThumbnailCacheKey(
final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
return _getThumbnailCacheKey(asset.remoteId!, type);
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
}
String _getThumbnailCacheKey(final String id, final ThumbnailFormat type) {
String getThumbnailCacheKeyForRemoteId(
final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
if (type == ThumbnailFormat.WEBP) {
return 'thumbnail-image-$id';
} else {
@@ -32,7 +35,8 @@ String getAlbumThumbnailUrl(
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!,
type: type,);
}
String getAlbumThumbNailCacheKey(
@@ -42,7 +46,10 @@ String getAlbumThumbNailCacheKey(
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
return getThumbnailCacheKeyForRemoteId(
album.thumbnail.value!.remoteId!,
type: type,
);
}
String getImageUrl(final Asset asset) {
@@ -53,7 +60,7 @@ String getImageCacheKey(final Asset asset) {
return '${asset.id}_fullStage';
}
String _getThumbnailUrl(
String getThumbnailUrlForRemoteId(
final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {

View File

@@ -17,14 +17,12 @@ extension WithETag on AssetApi {
String? userId,
bool? isFavorite,
bool? isArchived,
bool? withoutThumbs,
}) async {
final response = await getAllAssetsWithHttpInfo(
ifNoneMatch: eTag,
userId: userId,
isFavorite: isFavorite,
isArchived: isArchived,
withoutThumbs: withoutThumbs,
);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
void handleShareAssets(
WidgetRef ref,
BuildContext context,
List<Asset> selection,
) {
showDialog(
context: context,
builder: (BuildContext buildContext) {
ref
.watch(shareServiceProvider)
.shareAssets(selection.toList())
.then((_) => Navigator.of(buildContext).pop());
return const ShareDialog();
},
barrierDismissible: false,
);
}
Future<void> handleArchiveAssets(
WidgetRef ref,
BuildContext context,
List<Asset> selection, {
bool shouldArchive = true,
ToastGravity toastGravity = ToastGravity.BOTTOM,
}) async {
if (selection.isNotEmpty) {
await ref
.read(assetProvider.notifier)
.toggleArchive(selection, shouldArchive);
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
final archiveOrLibrary = shouldArchive ? 'archive' : 'library';
if (context.mounted) {
ImmichToast.show(
context: context,
msg: 'Moved ${selection.length} $assetOrAssets to $archiveOrLibrary',
gravity: toastGravity,
);
}
}
}
Future<void> handleFavoriteAssets(
WidgetRef ref,
BuildContext context,
List<Asset> selection, {
bool shouldFavorite = true,
ToastGravity toastGravity = ToastGravity.BOTTOM,
}) async {
if (selection.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleFavorite(selection, shouldFavorite);
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
final toastMessage = shouldFavorite
? 'Added ${selection.length} $assetOrAssets to favorites'
: 'Removed ${selection.length} $assetOrAssets from favorites';
if (context.mounted) {
ImmichToast.show(
context: context,
msg: toastMessage,
gravity: ToastGravity.BOTTOM,
);
}
}
}

View File

@@ -29,15 +29,22 @@ doc/AssetResponseDto.md
doc/AssetStatsResponseDto.md
doc/AssetTypeEnum.md
doc/AudioCodec.md
doc/AuditApi.md
doc/AuditDeletesResponseDto.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md
doc/BulkIdResponseDto.md
doc/BulkIdsDto.md
doc/CLIPConfig.md
doc/CLIPMode.md
doc/CQMode.md
doc/ChangePasswordDto.md
doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md
doc/CheckExistingAssetsResponseDto.md
doc/ClassificationConfig.md
doc/Colorspace.md
doc/CreateAlbumDto.md
doc/CreateProfileImageResponseDto.md
doc/CreateTagDto.md
@@ -50,6 +57,7 @@ doc/DeleteAssetStatus.md
doc/DownloadArchiveInfo.md
doc/DownloadInfoDto.md
doc/DownloadResponseDto.md
doc/EntityType.md
doc/ExifResponseDto.md
doc/ImportAssetDto.md
doc/JobApi.md
@@ -65,7 +73,9 @@ doc/LogoutResponseDto.md
doc/MapMarkerResponseDto.md
doc/MemoryLaneResponseDto.md
doc/MergePersonDto.md
doc/ModelType.md
doc/OAuthApi.md
doc/OAuthAuthorizeResponseDto.md
doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
@@ -77,11 +87,11 @@ doc/PersonApi.md
doc/PersonResponseDto.md
doc/PersonUpdateDto.md
doc/QueueStatusDto.md
doc/RecognitionConfig.md
doc/SearchAlbumResponseDto.md
doc/SearchApi.md
doc/SearchAssetDto.md
doc/SearchAssetResponseDto.md
doc/SearchConfigResponseDto.md
doc/SearchExploreItem.md
doc/SearchExploreResponseDto.md
doc/SearchFacetCountResponseDto.md
@@ -105,6 +115,7 @@ doc/SystemConfigApi.md
doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md
doc/SystemConfigMachineLearningDto.md
doc/SystemConfigOAuthDto.md
doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigStorageTemplateDto.md
@@ -134,6 +145,7 @@ lib/api.dart
lib/api/album_api.dart
lib/api/api_key_api.dart
lib/api/asset_api.dart
lib/api/audit_api.dart
lib/api/authentication_api.dart
lib/api/job_api.dart
lib/api/o_auth_api.dart
@@ -176,6 +188,7 @@ lib/model/asset_response_dto.dart
lib/model/asset_stats_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/audio_codec.dart
lib/model/audit_deletes_response_dto.dart
lib/model/auth_device_response_dto.dart
lib/model/bulk_id_response_dto.dart
lib/model/bulk_ids_dto.dart
@@ -184,6 +197,11 @@ lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart
lib/model/check_existing_assets_response_dto.dart
lib/model/classification_config.dart
lib/model/clip_config.dart
lib/model/clip_mode.dart
lib/model/colorspace.dart
lib/model/cq_mode.dart
lib/model/create_album_dto.dart
lib/model/create_profile_image_response_dto.dart
lib/model/create_tag_dto.dart
@@ -196,6 +214,7 @@ lib/model/delete_asset_status.dart
lib/model/download_archive_info.dart
lib/model/download_info_dto.dart
lib/model/download_response_dto.dart
lib/model/entity_type.dart
lib/model/exif_response_dto.dart
lib/model/import_asset_dto.dart
lib/model/job_command.dart
@@ -210,6 +229,8 @@ lib/model/logout_response_dto.dart
lib/model/map_marker_response_dto.dart
lib/model/memory_lane_response_dto.dart
lib/model/merge_person_dto.dart
lib/model/model_type.dart
lib/model/o_auth_authorize_response_dto.dart
lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
@@ -219,10 +240,10 @@ lib/model/people_update_item.dart
lib/model/person_response_dto.dart
lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart
lib/model/recognition_config.dart
lib/model/search_album_response_dto.dart
lib/model/search_asset_dto.dart
lib/model/search_asset_response_dto.dart
lib/model/search_config_response_dto.dart
lib/model/search_explore_item.dart
lib/model/search_explore_response_dto.dart
lib/model/search_facet_count_response_dto.dart
@@ -243,6 +264,7 @@ lib/model/smart_info_response_dto.dart
lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart
lib/model/system_config_machine_learning_dto.dart
lib/model/system_config_o_auth_dto.dart
lib/model/system_config_password_login_dto.dart
lib/model/system_config_storage_template_dto.dart
@@ -292,6 +314,8 @@ test/asset_response_dto_test.dart
test/asset_stats_response_dto_test.dart
test/asset_type_enum_test.dart
test/audio_codec_test.dart
test/audit_api_test.dart
test/audit_deletes_response_dto_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart
test/bulk_id_response_dto_test.dart
@@ -301,6 +325,11 @@ test/check_duplicate_asset_dto_test.dart
test/check_duplicate_asset_response_dto_test.dart
test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart
test/classification_config_test.dart
test/clip_config_test.dart
test/clip_mode_test.dart
test/colorspace_test.dart
test/cq_mode_test.dart
test/create_album_dto_test.dart
test/create_profile_image_response_dto_test.dart
test/create_tag_dto_test.dart
@@ -313,6 +342,7 @@ test/delete_asset_status_test.dart
test/download_archive_info_test.dart
test/download_info_dto_test.dart
test/download_response_dto_test.dart
test/entity_type_test.dart
test/exif_response_dto_test.dart
test/import_asset_dto_test.dart
test/job_api_test.dart
@@ -328,7 +358,9 @@ test/logout_response_dto_test.dart
test/map_marker_response_dto_test.dart
test/memory_lane_response_dto_test.dart
test/merge_person_dto_test.dart
test/model_type_test.dart
test/o_auth_api_test.dart
test/o_auth_authorize_response_dto_test.dart
test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
@@ -340,11 +372,11 @@ test/person_api_test.dart
test/person_response_dto_test.dart
test/person_update_dto_test.dart
test/queue_status_dto_test.dart
test/recognition_config_test.dart
test/search_album_response_dto_test.dart
test/search_api_test.dart
test/search_asset_dto_test.dart
test/search_asset_response_dto_test.dart
test/search_config_response_dto_test.dart
test/search_explore_item_test.dart
test/search_explore_response_dto_test.dart
test/search_facet_count_response_dto_test.dart
@@ -368,6 +400,7 @@ test/system_config_api_test.dart
test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart
test/system_config_job_dto_test.dart
test/system_config_machine_learning_dto_test.dart
test/system_config_o_auth_dto_test.dart
test/system_config_password_login_dto_test.dart
test/system_config_storage_template_dto_test.dart

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.74.0
- API version: 1.77.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -113,6 +113,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |
*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset |
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
*AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |
@@ -123,6 +124,7 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} |
*OAuthApi* | [**authorizeOAuth**](doc//OAuthApi.md#authorizeoauth) | **POST** /oauth/authorize |
*OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback |
*OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config |
*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |
@@ -139,7 +141,6 @@ Class | Method | HTTP request | Description
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config |
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
@@ -204,14 +205,20 @@ Class | Method | HTTP request | Description
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md)
- [AuditDeletesResponseDto](doc//AuditDeletesResponseDto.md)
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md)
- [CLIPConfig](doc//CLIPConfig.md)
- [CLIPMode](doc//CLIPMode.md)
- [CQMode](doc//CQMode.md)
- [ChangePasswordDto](doc//ChangePasswordDto.md)
- [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
- [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
- [ClassificationConfig](doc//ClassificationConfig.md)
- [Colorspace](doc//Colorspace.md)
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- [CreateTagDto](doc//CreateTagDto.md)
@@ -224,6 +231,7 @@ Class | Method | HTTP request | Description
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- [DownloadInfoDto](doc//DownloadInfoDto.md)
- [DownloadResponseDto](doc//DownloadResponseDto.md)
- [EntityType](doc//EntityType.md)
- [ExifResponseDto](doc//ExifResponseDto.md)
- [ImportAssetDto](doc//ImportAssetDto.md)
- [JobCommand](doc//JobCommand.md)
@@ -238,6 +246,8 @@ Class | Method | HTTP request | Description
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [ModelType](doc//ModelType.md)
- [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
@@ -247,10 +257,10 @@ Class | Method | HTTP request | Description
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [RecognitionConfig](doc//RecognitionConfig.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
- [SearchAssetDto](doc//SearchAssetDto.md)
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
- [SearchConfigResponseDto](doc//SearchConfigResponseDto.md)
- [SearchExploreItem](doc//SearchExploreItem.md)
- [SearchExploreResponseDto](doc//SearchExploreResponseDto.md)
- [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
@@ -271,6 +281,7 @@ Class | Method | HTTP request | Description
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigJobDto](doc//SystemConfigJobDto.md)
- [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md)
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
- [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)

View File

@@ -380,7 +380,7 @@ 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)
# **getAllAssets**
> List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch)
> List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch)
@@ -408,12 +408,12 @@ final api_instance = AssetApi();
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isFavorite = true; // bool |
final isArchived = true; // bool |
final withoutThumbs = true; // bool | Include assets without thumbnails
final skip = 8.14; // num |
final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime |
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
try {
final result = api_instance.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch);
final result = api_instance.getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAllAssets: $e\n');
@@ -427,8 +427,8 @@ Name | Type | Description | Notes
**userId** | **String**| | [optional]
**isFavorite** | **bool**| | [optional]
**isArchived** | **bool**| | [optional]
**withoutThumbs** | **bool**| Include assets without thumbnails | [optional]
**skip** | **num**| | [optional]
**updatedAfter** | **DateTime**| | [optional]
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
### Return type
@@ -1368,8 +1368,6 @@ Name | Type | Description | Notes
Update an asset
### Example
```dart
import 'package:openapi/api.dart';

View File

@@ -21,6 +21,7 @@ Name | Type | Description | Notes
**livePhotoVideoId** | **String** | | [optional]
**originalFileName** | **String** | |
**originalPath** | **String** | |
**owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional]
**ownerId** | **String** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
**resized** | **bool** | |

73
mobile/openapi/doc/AuditApi.md generated Normal file
View File

@@ -0,0 +1,73 @@
# openapi.api.AuditApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getAuditDeletes**](AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
# **getAuditDeletes**
> AuditDeletesResponseDto getAuditDeletes(entityType, after, userId)
### 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 = AuditApi();
final entityType = ; // EntityType |
final after = 2013-10-20T19:20:30+01:00; // DateTime |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.getAuditDeletes(entityType, after, userId);
print(result);
} catch (e) {
print('Exception when calling AuditApi->getAuditDeletes: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**entityType** | [**EntityType**](.md)| |
**after** | **DateTime**| |
**userId** | **String**| | [optional]
### Return type
[**AuditDeletesResponseDto**](AuditDeletesResponseDto.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)

View File

@@ -0,0 +1,16 @@
# openapi.model.AuditDeletesResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ids** | **List<String>** | | [default to const []]
**needsFullSync** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

18
mobile/openapi/doc/CLIPConfig.md generated Normal file
View File

@@ -0,0 +1,18 @@
# openapi.model.CLIPConfig
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**enabled** | **bool** | |
**mode** | [**CLIPMode**](CLIPMode.md) | | [optional]
**modelName** | **String** | |
**modelType** | [**ModelType**](ModelType.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,4 +1,4 @@
# openapi.model.SearchConfigResponseDto
# openapi.model.CLIPMode
## Load the model package
```dart
@@ -8,7 +8,6 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**enabled** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

14
mobile/openapi/doc/CQMode.md generated Normal file
View File

@@ -0,0 +1,14 @@
# openapi.model.CQMode
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,18 @@
# openapi.model.ClassificationConfig
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**enabled** | **bool** | |
**minScore** | **int** | |
**modelName** | **String** | |
**modelType** | [**ModelType**](ModelType.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

14
mobile/openapi/doc/Colorspace.md generated Normal file
View File

@@ -0,0 +1,14 @@
# openapi.model.Colorspace
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

14
mobile/openapi/doc/EntityType.md generated Normal file
View File

@@ -0,0 +1,14 @@
# openapi.model.EntityType
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

14
mobile/openapi/doc/ModelType.md generated Normal file
View File

@@ -0,0 +1,14 @@
# openapi.model.ModelType
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -9,6 +9,7 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**authorizeOAuth**](OAuthApi.md#authorizeoauth) | **POST** /oauth/authorize |
[**callback**](OAuthApi.md#callback) | **POST** /oauth/callback |
[**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config |
[**link**](OAuthApi.md#link) | **POST** /oauth/link |
@@ -16,6 +17,47 @@ Method | HTTP request | Description
[**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink |
# **authorizeOAuth**
> OAuthAuthorizeResponseDto authorizeOAuth(oAuthConfigDto)
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = OAuthApi();
final oAuthConfigDto = OAuthConfigDto(); // OAuthConfigDto |
try {
final result = api_instance.authorizeOAuth(oAuthConfigDto);
print(result);
} catch (e) {
print('Exception when calling OAuthApi->authorizeOAuth: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**oAuthConfigDto** | [**OAuthConfigDto**](OAuthConfigDto.md)| |
### Return type
[**OAuthAuthorizeResponseDto**](OAuthAuthorizeResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **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)
# **callback**
> LoginResponseDto callback(oAuthCallbackDto)
@@ -62,6 +104,8 @@ No authorization required
@deprecated use feature flags and /oauth/authorize
### Example
```dart
import 'package:openapi/api.dart';

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