Compare commits

...

117 Commits

Author SHA1 Message Date
Alex Tran
cc2486f3f1 chore: open-api 2026-01-12 15:55:32 +00:00
Hemendra Singh Shekhawat
7992fe85d6 fix(web): added background gradient for video time visibility (#25138)
* fix(web): added background gradient for video time visibility

* fix(web): removed background gradient and added shadow to text and icon
2026-01-12 09:46:23 -06:00
Yaros
afe925a55e fix(web): show relevant navbar options for partner assets (#24832)
* fix(web): show relevant navbar options for partner

* fix(web): AssetSelectControlBar on photos & search routes

* chore: remove duplicate AssetSelectControlBar from search

* chore: formatting fix

* chore: change let to const
2026-01-12 09:41:33 -06:00
Daniel Dietzler
5e3f5f2b55 fix: unlock properties after successful sidecar write (#25168) 2026-01-12 14:01:38 +01:00
Jason Rasmussen
d4ad523eb3 refactor(web): user app settings (#25177) 2026-01-10 07:58:50 -05:00
Brandon Wees
e8c80d88a5 feat: image editing (#24155) 2026-01-09 17:59:52 -05:00
Jason Rasmussen
76241a7b2b refactor: user settings (#25166) 2026-01-09 17:11:07 -05:00
Jason Rasmussen
1e4af9731d refactor: modals (#25163) 2026-01-09 15:05:20 -05:00
Noel S
88327fb872 fix(mobile): remove weird zooming behaviour on videos and play/pause button delay (#24006)
disable scale gestures
2026-01-09 13:14:07 -06:00
Jason Rasmussen
702499b97d refactor: modals (#25162) 2026-01-09 13:03:57 -05:00
shenlong
da248414af refactor(mobile): form & form field (#25042)
* refactor: form & form field

* chore: remove unused components

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-09 09:26:36 -06:00
renovate[bot]
af2c232c87 chore(deps): update github-actions (major) (#25160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 14:55:39 +00:00
Yaros
cca037b03c fix(web): person asset count doesn't update when navigating (#24438) 2026-01-09 15:55:23 +01:00
renovate[bot]
1d71bb5a79 chore(deps): update ghcr.io/jdx/mise docker tag to v2026 (#25159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 14:52:24 +00:00
renovate[bot]
ee4f2c735d chore(deps): update immich-app/devtools action to v1.1.1 (#25066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 15:42:49 +01:00
Min Idzelis
4d559a63ec fix: properly fix asset-viewer delete action, add tests (#25149)
Update timeline manager before nav, add e2e regression tests
2026-01-09 09:20:42 -05:00
Robert Schäfer
573e9b0d52 refactor(dev): dockerify auth-server (#24377)
Description
-----------

A while ago I asked on Discord if you people would be interested in removing incompatibilities with rootless docker. See: https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592

The e2e tests in `e2e/src/api/specs/oauth.e2e-spec.ts` depend on a docker feature [host-gateway](https://docs.docker.com/reference/cli/dockerd/#configure-host-gateway-ip) that seemingly does not work on rootless docker.

So the suggested change is to dockerify the `auth-server` and not run it on the docker host.

I would love to receive feedback on this PR and feel free to request further improvements. Things that come to my mind:

* Compile typescript instead of using `tsx`
* Add hot-reloading of source files in `auth-server/` for development
* Add `eslint` configuration for the new folder

How Has This Been Tested?
------------------------

I'm running both default and rootless docker on my machine with [docker contexts](https://docs.docker.com/engine/manage-resources/contexts/):
```
docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

If I follow the steps from the [documentation](https://docs.immich.app/developer/testing) then `oauth.e2e-spec.ts` will fail because the `auth-server` on my host can't be reached.

The tests pass after these steps:
1. `git switch refactor-auth-server-as-service`
2. `make e2e`
3. In another terminal `cd e2e`
4. `pnpm run test src/api/specs/oauth.e2e-spec.ts` passes

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2026-01-09 08:59:11 -05:00
bo0tzz
a2502109ab fix: use my.immich.app as url placeholder in docs (#25153) 2026-01-09 11:46:55 +00:00
Timon
3cdece4945 fix(server): Document HTTP 200 response for duplicate uploads in OpenAPI (#25148)
* fix(server): Document HTTP 200 response for duplicate uploads in OpenAPI

* fix 201

* rename
2026-01-08 23:52:31 -05:00
Daniel Dietzler
520b825511 refactor: album page (#25140) 2026-01-08 22:27:20 +00:00
Jason Rasmussen
191401f2f1 fix: add asset upload medium test (#25144) 2026-01-08 22:01:25 +00:00
Jason Rasmussen
8136d7fd54 refactor(web): tag service (#25142) 2026-01-08 16:37:58 -05:00
Timon
5d1e486478 fix(server): avoid upserting empty metadata array (#25143) 2026-01-08 22:33:35 +01:00
Brandon Wees
85b0b97ef2 fix(web): apply changes to cursor.current instead of asset (#25136) 2026-01-08 22:31:41 +01:00
Jason Rasmussen
471fab0591 refactor: delete confirm modal (#25135) 2026-01-08 15:59:26 -05:00
Jason Rasmussen
6997ed83c4 refactor(web): set birthdate (#25139) 2026-01-08 15:41:20 -05:00
Jason Rasmussen
a2ba36c16d feat: bulk asset metadata endpoints (#25133) 2026-01-08 14:52:16 -05:00
Alex
109c79125d fix: description does not rerender when navigating between assets (#25137) 2026-01-08 13:32:43 -06:00
Jason Rasmussen
fbd49e0b79 refactor: memory lane (#25134) 2026-01-08 12:40:17 -05:00
Alex
1f20b6471c feat: use fastlane sigh to manage signing profiles (#25089)
* feat: use fastlane sigh to manage signing profiles

* remove unused secrects

* remove unused fallback
2026-01-08 03:02:21 +00:00
Alex
1d6a9f6e80 feat: free up space (#24999)
* feat(server): Support camera `make`, `model`, and `lensModel` in Storage Template (#24650)

* add support for make, model, lensModel in storage template

* no pkg lock

* Apply suggestion from @danieldietzler

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

* query and formatting

---------

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

* wip: copy-writing

* feat: cutoff date preset options and filter options

* fix: don't include iCloud Shared Album

* chore: message about excluding shared album assets

* feat: show preview in a separate page

* feat: show clean up hint modal after success deletion

* pr feedback

* pr feedback

* pr feedback

---------

Co-authored-by: Rahul Kumar Saini <rahul-kumar-saini@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-01-07 20:55:28 -06:00
Min Idzelis
0a9f1a3cbf feat: cache asset info for prev/next navigation (#24482)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-07 19:10:29 -05:00
Jason Rasmussen
4f803832ad refactor: download action (#25124) 2026-01-07 22:01:20 +00:00
Mees Frensel
ef4aec7398 chore: refactor ErrorLayout (#25094)
* chore: refactor ErrorLayout

* Align links to top
2026-01-07 15:49:04 -06:00
Jason Rasmussen
5bb3492616 refactor: favorite action (#25121) 2026-01-07 21:21:19 +00:00
Min Idzelis
78229baeab feat: improve asset-viewer next/prev perf and standardize preloading behavior (#24422)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-07 21:17:12 +01:00
Timon
81f269e2a9 fix(docs): Use full git clone in CI to enable accurate last update times (#25120) 2026-01-07 17:19:43 +00:00
Timon
225b0f9377 chore: use setup-uv action to install python (#25109)
chore: update GitHub Actions workflow to use setup-uv action to install python
2026-01-07 10:46:04 -05:00
Alex
30b90f9baa fix: propagate iCloud Shared Album flag (#25060)
* fix: propagate iCloud Shared Album flag

* chore: add migration
2026-01-06 19:46:25 -06:00
Jason Rasmussen
1293e473ca refactor: cast button (#25101) 2026-01-06 18:51:19 -05:00
Jason Rasmussen
1a24a2d35e refactor: asset viewer navbar actions (#25091) 2026-01-06 17:35:37 -05:00
Jason Rasmussen
f0f1687c79 refactor: asset view navbar onclose (#25087) 2026-01-06 15:41:53 +00:00
lif
ded980bfc3 fix(web): improve text contrast in minimized upload panel (#25075)
The minimized upload status buttons in dark mode had poor text
contrast because they used `text-gray-200` on colored backgrounds.
Changed to `text-light` which provides better contrast for both
light and dark modes on `bg-primary` and `bg-danger` backgrounds.

Fixes #24683

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-06 15:23:28 +00:00
fabb
4cb56edebf fix: enter now submits the date modals (#25053)
* fix: enter now submits the date modals

* use FormModal

* apply prettier

* fix unit test
2026-01-06 09:08:54 -06:00
Daniel Dietzler
c411151560 chore: docs for contributing (#25082) 2026-01-06 09:07:44 -06:00
Brandon Wees
f52bd9f38a feat: use prettier for i18n translations (#24623) 2026-01-06 15:02:10 +00:00
Mees Frensel
006d02cfaf fix(web): server stats layout (#25085)
fix: server stats layout
2026-01-06 09:10:38 -05:00
lif
263f96da87 fix(server): search statistics with personIds returns 500 (#25074)
The searchAssetBuilder was incorrectly adding withFacesAndPeople
select when personIds was provided. This caused a SQL error because
the subquery referenced asset.id which wasn't selected in statistics
queries (only count(*) was selected).

The fix removes personIds from the condition that triggers adding
faces data to the select. The hasPeople filter (for personIds) is
still applied correctly for filtering.

Fixes #25003

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-06 08:54:12 -05:00
Calvin Bochulak
f22affd836 feat(web): star rating keyboard shortcut (#24620)
Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-06 12:56:29 +00:00
Yaros
f5667cefd4 fix(web): broken asset urls if shared link has photos in name (#24451) 2026-01-06 13:49:08 +01:00
Hemendra Singh Shekhawat
7efce389b2 fix(web): long text taking more width than expected in duplicate manager (#24547) 2026-01-06 12:47:41 +00:00
lif
f59cff4f5d fix(web): use asset date for change date popup when single asset selected (#25076) 2026-01-06 13:37:51 +01:00
Jason Rasmussen
984f06ac40 refactor: asset viewer (#25059) 2026-01-05 21:02:01 +00:00
renovate[bot]
9d4a12dfd4 chore(deps): update node.js to v24.12.0 (#25046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 20:13:34 +00:00
renovate[bot]
94730567ab fix(deps): update formatjs monorepo (major) (#25049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 20:59:06 +01:00
Jason Rasmussen
57db5e64de chore(web): bump immich/ui for tooltips (#24632)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 19:51:03 +00:00
Jason Rasmussen
4d32968f2b refactor: redirect code (#25054) 2026-01-05 14:39:28 -05:00
renovate[bot]
10989e6927 fix(deps): update typescript-projects (#25047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 16:49:09 +00:00
Jason Rasmussen
62cc12be3c refactor: asset from param (#25041) 2026-01-05 11:26:58 -05:00
Jason Rasmussen
1874557b95 fix: empty action context menu (#25043) 2026-01-05 11:26:23 -05:00
renovate[bot]
9a78547bf0 chore(deps): update dependency @types/node to ^24.10.4 (#25044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:19:55 +00:00
renovate[bot]
0b1bd9deb1 chore(deps): update dependency vite-tsconfig-paths to v6 (#25048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:18:22 +01:00
renovate[bot]
7202179d63 chore(deps): update grafana/grafana docker tag to v12.3.1 (#25045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:16:58 +01:00
Jason Rasmussen
519a7df4cd refactor: trash page actions (#25039) 2026-01-05 10:48:55 -05:00
renovate[bot]
3762728c84 chore(deps): update docker.io/valkey/valkey:9 docker digest to 5463044 (#24800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:47:19 +01:00
renovate[bot]
bc3fa2b3fb chore(deps): update prom/prometheus docker digest to 2b6f734 (#24801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:46:53 +01:00
Jason Rasmussen
57fca378bc refactor: page container (#25038) 2026-01-05 10:44:29 -05:00
Flozza
eb718145c0 docs: config options for hardware transcoding (#24853) 2026-01-05 16:40:53 +01:00
Felipe Cury
c87c1866ae fix: grammar in trigger_description string (#24867)
Fix typo in trigger_description string
2026-01-05 15:36:48 +00:00
Nikhil Alapati
b190423d96 fix(server): migrate motion part of live photo (#24688)
Co-authored-by: Nikhil Alapati <nikhilalapati@meta.com>
2026-01-05 15:26:45 +00:00
Daniel Ramos
edd3ab7cc9 feat(server): implement switchable logging formats (console/json) (#24791)
* feat(server): add LogFormat enum and configuration

* feat(server): add structured logging formatters

* feat(server): implement switchable logging formats (console/json)

* Revert "feat(server): add LogFormat enum and configuration"

This reverts commit 565e95ae68.

* feat(server): implement JSON logging using NestJS native support

* refactor: rename LOG_FORMAT to IMMICH_LOG_FORMAT for consistency

* docs: add IMMICH_LOG_FORMAT documentation

* chore: format environment-variables.md

* chore: format monitoring.md
2026-01-05 09:21:02 -06:00
Jason Rasmussen
4147f1d912 fix: duplicate api call on new library page (#25036) 2026-01-05 10:03:44 -05:00
Jason Rasmussen
e4311da1a4 fix: shared-link-mapper (#24794) 2026-01-05 10:03:35 -05:00
Matthew Momjian
b7bb118c00 chore(deployment): add healthcheck option for DB (#25024) 2026-01-05 14:30:33 +01:00
Yaros
21f7314907 feat(web): undo delete single asset (#24439) 2026-01-05 13:31:02 +01:00
Timon
2541011eaa fix(web): duplicate key error and enable expiration editing for expired shared links (#24686)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 12:12:44 +00:00
Alex
18d8cc4449 fix: search input has incorrect focus state after closing the search filter modal (#24886) 2026-01-05 12:38:43 +01:00
Ahmed Mahmoud Aref
8e8a2f997e feat: show asset owners for editors in shared albums (#24890) 2026-01-05 11:31:23 +00:00
Jorge Montejo
86e5c611ec fix: import config from json (#25030)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 11:28:08 +00:00
skrmc
e700bb5467 fix(mobile): hide delete action for remote-only assets (#25010) 2026-01-05 07:02:39 +00:00
GustavJones
a1aa2b807b feat(web): Add coordinate pair location searching. (#24799)
* feat(web): Add coordinate pair searching within the change location modal.

Adds simple logic to try and parse a coordinate pair in the format
`LATITUDE, LONGITUDE` as provided from Google Maps if a coordinate is
copied to update the coordinates automatically.

* Add checks for valid coordinate pairs

* Update formatting and fix linting issues
2026-01-04 13:16:23 -06:00
immich-tofu[bot]
abea5a53de chore: linting (#7532)
* chore: linting

* fix: broken tests

* fix: formatting
2026-01-04 16:05:56 +00:00
Matthew Momjian
bcf6685643 chore(server): Vchord 1.0 support (#23845)
vc 1
2026-01-04 00:01:11 -05:00
Diego Saint Esteben
bd27898ea9 fix(server): update exiftool-vendored to v34.3 for correct colon-less timezone parsing (#24979) 2026-01-02 20:31:31 +01:00
Savely Krasovsky
3321c1a9df feat(ml): update ONNX Runtime, OpenVINO and ROCm stack (#23458) 2026-01-01 12:17:55 -05:00
renovate[bot]
72a898d89d chore(deps): update github-actions (#24606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-31 12:23:36 +00:00
Rahul Kumar Saini
a16c5955d7 feat(server): Support camera make, model, and lensModel in Storage Template (#24650)
* add support for make, model, lensModel in storage template

* no pkg lock

* Apply suggestion from @danieldietzler

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

* query and formatting

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-29 21:55:06 +00:00
Keanu Czirjak
e87bfa548a fix(web): let slideshow videos play (#19601) (#24914)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-29 18:03:55 +00:00
Min Idzelis
369a30e227 fix: canceling a bucket while findMonthGroupForAsset is waiting fails (#24898) 2025-12-29 09:28:37 -06:00
Lauritz Tieste
0df618feee feat: Hide/show controls when zoom state changes (#24784)
feat: hide/show controls based on zoom state in asset viewer
2025-12-27 16:02:42 -06:00
Daniel Dietzler
363b9276eb fix: album card timezone (#24855) 2025-12-26 21:40:07 -06:00
idubnori
36d7dd9319 feat(mobile): album options to kebab menu (#24204)
* feat(mobile): refactor album options into kebab menu for improved UX

* feat(mobile): update BaseActionButton to use iconColor for text styling and add delete button color in DriftRemoteAlbumOption

* feat: const Divider(height: 1)

* fix(mobile): update icon color for album options menu button

* chore: refactor

* chore: refactor

* add test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-26 18:46:05 +00:00
Peter Ombodi
a57c4d9a9e fix(drift backup notifier): add lifecycle guards and dispose logging (#24806)
* fix(drift backup notifier): add lifecycle guards and dispose logging

* fix(drift backup notifier): re-read notifiers in callbacks to avoid disposed backup notifier

* fix(drift backup notifier): increase the log level to warning.

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2025-12-26 18:44:07 +00:00
Marcin Wróblewski
724948d36d feat(mobile): use tabular figures in backup info card (#24820)
* feat(mobile): use tabular figures in backup info card

during large (initial) backups current non-tabular figures are jumping around the UI, making the UI hard to follow. this change makes sure there’s no jump in text width between e.g. 7888 to 7111

* chore: use const
2025-12-25 22:27:33 -06:00
Min Idzelis
83f8065f10 fix: autogrow textarea bugs during animation (#24481) 2025-12-24 13:21:08 +01:00
renovate[bot]
e63e8e2517 chore(deps): update machine-learning (#24610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-23 03:12:13 +00:00
Jason Rasmussen
01e3b8e5df refactor: form modals (#24790) 2025-12-22 14:15:23 -05:00
Jason Rasmussen
5a7c9a252c feat: disable admin setup (#24628) 2025-12-22 14:15:08 -05:00
Jason Rasmussen
f99f5f4f91 refactor: map setting modal (#24789) 2025-12-22 13:54:14 -05:00
Jason Rasmussen
8ad27c7cea refactor: slideshow modal (#24788) 2025-12-22 18:44:53 +00:00
Jason Rasmussen
edc21ed746 fix(web): stale album info (#24787) 2025-12-22 19:38:57 +01:00
Jason Rasmussen
dd744f8ee3 refactor: album edit modal (#24786) 2025-12-22 13:33:49 -05:00
Min Idzelis
f6f9a3abb4 fix: task never rejected on cancel, add tests (#24418) 2025-12-22 13:12:43 -05:00
Jason Rasmussen
1c156a179b feat: shared link edit (#24783) 2025-12-22 11:47:06 -05:00
Jason Rasmussen
952f189d8b feat: prefer admin settings page over users page (#24780) 2025-12-22 11:31:22 -05:00
Jason Rasmussen
40e750e8be refactor: api key service (#24779) 2025-12-22 11:09:11 -05:00
Jason Rasmussen
c7510d572a chore: move models (#24778) 2025-12-22 15:23:57 +00:00
Jason Rasmussen
165f9e15ee feat: modal routes (#24726)
feat: new user route
2025-12-22 15:04:08 +00:00
Mert
dfdbb773ce fix(web): display jxl original (#24766)
display jxl original
2025-12-21 20:10:22 -06:00
bo0tzz
f053ce548d fix: product keys wording in commercial guidelines faq (#24765) 2025-12-21 19:35:21 +00:00
bo0tzz
d7c28470ee feat: focus jumped-to item in timeline (#24738) 2025-12-21 08:52:52 -06:00
Alex
28f6064240 feat: workflow ui (#24190)
* feat: workflow ui

* wip

* wip

* wip

* pr feedback

* refactor: picker field

* use showDialog directly

* better test

* refactor step selection modal

* move enable button to info form

* use  for Props

* pr feedback

* refactor ActionItem

* refactor ActionItem

* more refactor

* fix: new schemaformfield has value of the same type

* chore: clean up
2025-12-20 21:07:07 -06:00
Alex
4b3b458bb6 chore: update info.plist app version in bump-version (#24722) 2025-12-20 21:02:11 -06:00
Sergey Katsubo
4736b4e3e8 chore(server): improve log messages (#24744)
* Clarify the "asset not found" log during thumbnail generation: it's about database

* Move not found sidecars to verbose level instead of "old=null, new=null" at debug

* Log memory creation at default level

* Add explicit log for missing exif date time

Instead of: Date and time is undefined using exifTag undefined for asset ...

* Log database migration start/end at default level

Currently, these messages are logged as "debug". But they are not printed
when debug or verbose level is set. This is due to the known limitation:
SystemConfigService sets LogLevel later on, after migrations run.
2025-12-20 21:00:34 -06:00
Paul Makles
a17f188e97 fix(maintenance): prevent enable/disable maintenance CLI hanging on occasion (#24713)
* fix(maintenance): prevent CLI hanging on occassion
fix(maintenance): always ack messages
fix(maintenance): ensure Redis is connected first

* chore(maintenance): validate app restart responses

* chore: mock the app restart callback

* fix: ack may not exist depending on caller

* refactor: move one shot into app.repository

* fix: send correct state in one shot

* chore: log restart event
2025-12-19 17:13:00 -05:00
Jason Rasmussen
5b80323326 refactor: library service (#24725) 2025-12-19 13:20:35 -05:00
Jason Rasmussen
1425b3da6b refactor: admin card (#24723) 2025-12-19 12:47:04 -05:00
535 changed files with 21659 additions and 9949 deletions

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
24.11.1
24.12.0

View File

@@ -30,18 +30,6 @@ on:
required: true
IOS_CERTIFICATE_PASSWORD:
required: true
IOS_PROVISIONING_PROFILE:
required: true
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
FASTLANE_TEAM_ID:
required: true
pull_request:
@@ -96,7 +84,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -115,7 +103,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
@@ -165,14 +153,14 @@ jobs:
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -194,7 +182,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -240,35 +228,14 @@ jobs:
mkdir -p ~/.appstoreconnect/private_keys
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
- name: Import Certificate and Provisioning Profiles
- name: Import Certificate
env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios
run: |
# Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
# Decode provisioning profiles based on environment
if [[ "$ENVIRONMENT" == "development" ]]; then
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
ls -lh profile_dev*.mobileprovision
else
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
ls -lh profile*.mobileprovision
fi
- name: Create keychain and import certificate
env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
@@ -319,7 +286,7 @@ jobs:
security delete-keychain build.keychain || true
- name: Upload IPA artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa

View File

@@ -25,7 +25,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check out code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -35,7 +35,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -78,7 +78,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -87,7 +87,7 @@ jobs:
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -50,14 +50,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: '/language:${{matrix.language}}'

View File

@@ -60,10 +60,11 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -85,7 +86,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: docs-build-output
path: docs/build/

View File

@@ -125,13 +125,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Load parameters
id: parameters

View File

@@ -23,13 +23,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Destroy Docs Subdomain
env:

View File

@@ -22,7 +22,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout'
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}

View File

@@ -56,14 +56,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -136,13 +136,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}

View File

@@ -23,14 +23,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -159,7 +159,7 @@ jobs:
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -58,7 +58,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
@@ -74,7 +74,7 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}

View File

@@ -22,7 +22,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -55,7 +55,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -69,7 +69,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -114,7 +114,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -161,7 +161,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -203,7 +203,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -247,7 +247,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -285,7 +285,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -298,9 +298,9 @@ jobs:
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies
run: pnpm --filter=immich-web install --frozen-lockfile
run: pnpm --filter=immich-i18n install --frozen-lockfile
- name: Format
run: pnpm --filter=immich-web format:i18n
run: pnpm --filter=immich-i18n format:fix
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
@@ -333,7 +333,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -379,7 +379,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -418,7 +418,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -473,7 +473,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -505,7 +505,7 @@ jobs:
run: npx playwright test
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
@@ -534,7 +534,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -566,17 +566,14 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11
# cache: 'uv'
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
with:
python-version: 3.11
- name: Install dependencies
run: |
uv sync --extra cpu
@@ -610,7 +607,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -639,7 +636,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -661,7 +658,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -723,7 +720,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

31
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,31 @@
# Contributing to Immich
We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product!
## Getting started
To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers).
There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture).
## General
Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR.
## Finding work
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
## Use of generative AI
We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template.
## Feature freezes
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
* Sharing/Asset ownership
* (External) libraries
## Non-code contributions
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.

View File

@@ -1 +1 @@
24.11.1
24.12.0

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.3",
"@types/node": "^24.10.4",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -36,7 +36,7 @@
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}

View File

@@ -127,7 +127,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck:
test: redis-cli ping || exit 1
@@ -146,6 +146,8 @@ services:
ports:
- 5432:5432
shm_size: 128mb
healthcheck:
disable: false
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus:
# container_name: immich_prometheus

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -77,13 +77,15 @@ services:
- 5432:5432
shm_size: 128mb
restart: always
healthcheck:
disable: false
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
immich-prometheus:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
image: prom/prometheus@sha256:2b6f734e372c1b4717008f7d0a0152316aedd4d13ae17ef1e3268dbfaf68041b
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -95,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
image: grafana/grafana:12.3.1-ubuntu@sha256:d57f1365197aec34c4d80869d8ca45bb7787c7663904950dab214dfb40c1c2fd
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -69,6 +69,8 @@ services:
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
healthcheck:
disable: false
volumes:
model-cache:

View File

@@ -1 +1 @@
24.11.1
24.12.0

View File

@@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase product keys directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app

View File

@@ -22,7 +22,7 @@ Immich is known to work with Postgres versions `>= 14, < 19`.
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
The current accepted range for VectorChord is `>= 0.3, < 2.0`.
:::
## Specifying the connection URL

View File

@@ -4,6 +4,10 @@ sidebar_position: 2
# Setup
:::warning
Make sure to read the [`CONTRIBUTING.md`](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before you dive into the code.
:::
:::note
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:

View File

@@ -71,6 +71,22 @@ For RKMPP to work:
5. (Optional) Enable hardware decoding for optimal performance.
<details>
<summary>immich.json</summary>
If you use a [configuration file](/install/config-file.md), use the `accel` option to select the hardware (e.g. `qsv` for Intel or `nvenc` for Nvidia). Set `accelDecode` to `true` if you want hardware decoding.
```json
{
"ffmpeg": {
"accel": "qsv",
"accelDecode": true
}
}
```
</details>
#### Single Compose File
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.

View File

@@ -112,4 +112,40 @@ You can then make a new panel, specifying Prometheus as the data source for it.
-- TODO: add images and more details here
## Structured Logging
In addition to Prometheus metrics, Immich supports structured JSON logging which is ideal for log aggregation systems like Grafana Loki, ELK Stack, Datadog, Splunk, and others.
### Configuration
By default, Immich outputs human-readable console logs. To enable JSON logging, set the `IMMICH_LOG_FORMAT` environment variable:
```bash
IMMICH_LOG_FORMAT=json
```
:::tip
The default is `IMMICH_LOG_FORMAT=console` for human-readable logs with colors during development. For production deployments using log aggregation, use `IMMICH_LOG_FORMAT=json`.
:::
### JSON Log Format
When enabled, logs are output in structured JSON format:
```json
{"level":"log","pid":36,"timestamp":1766533331507,"message":"Initialized websocket server","context":"WebsocketRepository"}
{"level":"warn","pid":48,"timestamp":1766533331629,"message":"Unable to open /build/www/index.html, skipping SSR.","context":"ApiService"}
{"level":"error","pid":36,"timestamp":1766533331690,"message":"Failed to load plugin immich-core:","context":"Error"}
```
This format includes:
- `level`: Log level (log, warn, error, etc.)
- `pid`: Process ID
- `timestamp`: Unix timestamp in milliseconds
- `message`: Log message
- `context`: Service or component that generated the log
For more information on log formats, see [`IMMICH_LOG_FORMAT`](/install/environment-variables.md#general).
[prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml

View File

@@ -33,7 +33,7 @@ You can create a public link to share a group of photos or videos, or an album,
The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance.
```
https://immich.yourdomain.com/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
https://my.immich.app/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
```
### Creating a public share link

View File

@@ -34,6 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
@@ -43,6 +44,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -57,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}

View File

@@ -0,0 +1,6 @@
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25
RUN corepack enable
ADD package.json *.ts ./
RUN pnpm install
EXPOSE 2286
CMD ["pnpm", "run", "start"]

View File

@@ -125,7 +125,7 @@ const setup = async () => {
],
});
const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`);
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
const app = oidc.listen(port, host, onStart);
return () => app.close();
};

View File

@@ -0,0 +1,15 @@
{
"name": "@immich/e2e-auth-server",
"version": "0.1.0",
"type": "module",
"main": "auth-server.ts",
"scripts": {
"start": "tsx startup.ts"
},
"devDependencies": {
"jose": "^5.6.3",
"@types/oidc-provider": "^9.0.0",
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"
}
}

View File

@@ -0,0 +1,8 @@
import setup from './auth-server'
const teardown = await setup()
process.on('exit', () => {
teardown()
console.log('[e2e-auth-server] stopped')
process.exit(0)
})

View File

@@ -1 +1 @@
24.11.1
24.12.0

View File

@@ -1,6 +1,12 @@
name: immich-e2e
services:
e2e-auth-server:
build:
context: ../e2e-auth-server
ports:
- 2286:2286
immich-server:
container_name: immich-e2e-server
image: immich-server:latest
@@ -27,8 +33,6 @@ services:
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
volumes:
- ./test-assets:/test-assets
extra_hosts:
- 'auth-server:host-gateway'
depends_on:
redis:
condition: service_started

View File

@@ -22,12 +22,12 @@
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "file:../cli",
"@immich/e2e-auth-server": "file:../e2e-auth-server",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.3",
"@types/oidc-provider": "^9.0.0",
"@types/node": "^24.10.4",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
@@ -36,11 +36,9 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^34.0.0",
"exiftool-vendored": "^34.3.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
"oidc-provider": "^9.0.0",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
@@ -54,6 +52,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}

View File

@@ -1,3 +1,4 @@
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
import {
LoginResponseDto,
SystemConfigOAuthDto,
@@ -8,13 +9,12 @@ import {
} from '@immich/sdk';
import { createHash, randomBytes } from 'node:crypto';
import { errorDto } from 'src/responses';
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
const authServer = {
internal: 'http://auth-server:2286',
internal: 'http://e2e-auth-server:2286',
external: 'http://127.0.0.1:2286',
};

View File

@@ -20,7 +20,6 @@ describe('/shared-links', () => {
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let album: AlbumResponseDto;
let metadataAlbum: AlbumResponseDto;
let deletedAlbum: AlbumResponseDto;
let linkWithDeletedAlbum: SharedLinkResponseDto;
let linkWithPassword: SharedLinkResponseDto;
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
[album, deletedAlbum, metadataAlbum] = await Promise.all([
[album, deletedAlbum] = await Promise.all([
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
createAlbum(
{
createAlbumDto: {
albumName: 'metadata album',
assetIds: [asset1.id],
},
},
{ headers: asBearerAuth(user1.accessToken) },
),
]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
password: 'foo',
}),
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
type: SharedLinkType.Individual,
assetIds: [asset1.id],
showMetadata: true,
slug: 'metadata-album',
slug: 'metadata-slug',
}),
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
type: SharedLinkType.Individual,
assetIds: [asset1.id],
showMetadata: false,
}),
]);
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
});
it('should have correct asset count in meta tag for empty album', async () => {
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
});
});
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
);
});
it('should return metadata for album shared link', async () => {
it('should return metadata for individual shared link', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(0);
expect(body.album).toBeDefined();
expect(body.assets).toHaveLength(1);
expect(body.album).not.toBeDefined();
});
it('should not return metadata for album shared link without metadata', async () => {
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined();
expect(body.album).not.toBeDefined();
const asset = body.assets[0];
expect(asset).not.toHaveProperty('exifInfo');

View File

@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
if (!value) {
throw new Error('Ran out of random asset data');
}
return value;
};

View File

@@ -346,6 +346,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
duplicateId: null,
resized: true,
checksum: asset.checksum,
width: exifInfo.exifImageWidth ?? 1,
height: exifInfo.exifImageHeight ?? 1,
};
}

View File

@@ -1,3 +1,4 @@
import { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, Request, Route } from '@playwright/test';
import { basename } from 'node:path';
import {
@@ -63,15 +64,33 @@ export const setupTimelineMockApiRoutes = async (
});
await context.route('**/api/assets/*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
if (request.method() === 'GET') {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
let asset = getAsset(timelineRestData, assetId);
if (changes.assetDeletions.includes(asset!.id)) {
asset = {
...asset,
isTrashed: true,
} as AssetResponseDto;
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
}
await route.fallback();
});
await context.route('**/api/assets', async (route, request) => {
if (request.method() === 'DELETE') {
return route.fulfill({
status: 204,
});
}
await route.fallback();
});
await context.route('**/api/assets/*/ocr', async (route) => {
@@ -117,17 +136,28 @@ export const setupTimelineMockApiRoutes = async (
});
await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern);
if (!match) {
return route.continue();
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
if (albumsMatch) {
const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
}
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
return route.fallback();
});
await context.route('**/api/albums**', async (route, request) => {
const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
if (allAlbums) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
}
return route.fallback();
});
};

View File

@@ -0,0 +1,156 @@
import { faker } from '@faker-js/faker';
import { test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
const rng = new SeededRandom(529);
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos/:id', () => {
test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete photo advances to next (2x)', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete last photo advances to prev', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
});
test('Delete last photo advances to prev (2x)', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
});
});
test.describe('/trash/photos/:id', () => {
test('Delete trashed photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete trashed photo advances to next 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete trashed photo advances to prev', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
});
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
});
});
});

View File

@@ -463,7 +463,7 @@ test.describe('Timeline', () => {
});
changes.albumAdditions.push(...requestJson.ids);
});
await page.getByText('Done').click();
await page.getByText('Add assets').click();
await expect(put).resolves.toEqual({
ids: [
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',

View File

@@ -181,8 +181,12 @@ export const assetViewerUtils = {
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
.locator(
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
)
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {

View File

@@ -56,7 +56,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect
.poll(async () => {
@@ -85,7 +85,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect
.poll(async () => {

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vitest/config';
// skip `docker compose up` if `make e2e` was already run
const globalSetup: string[] = ['src/setup/auth-server.ts'];
const globalSetup: string[] = [];
try {
await fetch('http://127.0.0.1:2285/api/server-info/ping');
} catch {

5
i18n/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}

View File

@@ -5,6 +5,7 @@
"acknowledge": "Acknowledge",
"action": "Action",
"action_common_update": "Update",
"action_description": "A set of action to perform on the filtered assets",
"actions": "Actions",
"active": "Active",
"active_count": "Active: {count}",
@@ -15,9 +16,14 @@
"add_a_location": "Add a location",
"add_a_name": "Add a name",
"add_a_title": "Add a title",
"add_action": "Add action",
"add_action_description": "Click to add an action to perform",
"add_assets": "Add assets",
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
@@ -36,6 +42,7 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -467,10 +474,12 @@
"album_remove_user": "Remove user?",
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
"album_search_not_found": "No albums found matching your search",
"album_selected": "Album selected",
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
"album_summary": "Album summary",
"album_updated": "Album updated",
"album_updated_setting_description": "Receive an email notification when a shared album has new assets",
"album_upload_assets": "Upload assets from your computer and add to album",
"album_user_left": "Left {album}",
"album_user_removed": "Removed {user}",
"album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?",
@@ -488,6 +497,7 @@
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
"albums_feature_description": "Collections of assets that can be shared with other users.",
"albums_on_device_count": "Albums on device ({count})",
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
"all": "All",
"all_albums": "All albums",
"all_people": "All people",
@@ -524,10 +534,12 @@
"archived_count": "{count, plural, other {Archived #}}",
"are_these_the_same_person": "Are these the same person?",
"are_you_sure_to_do_this": "Are you sure you want to do this?",
"array_field_not_fully_supported": "Array fields require manual JSON editing",
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
"asset_added_to_album": "Added to album",
"asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated",
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
@@ -711,6 +723,8 @@
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"change_pin_code": "Change PIN code",
"change_trigger": "Change trigger",
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
"charging": "Charging",
@@ -722,6 +736,18 @@
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City",
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
"cleanup_confirm_prompt_title": "Remove from this device?",
"cleanup_deleted_assets": "Moved {count} assets to device trash",
"cleanup_deleting": "Moving to trash...",
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
"cleanup_found_assets": "Found {count} backed up assets",
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
"cleanup_preview_title": "Assets to remove ({count})",
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
"clear": "Clear",
"clear_all": "Clear all",
"clear_all_recent_searches": "Clear all recent searches",
@@ -787,6 +813,7 @@
"create_album": "Create album",
"create_album_page_untitled": "Untitled",
"create_api_key": "Create API key",
"create_first_workflow": "Create first workflow",
"create_library": "Create Library",
"create_link": "Create link",
"create_link_to_share": "Create link to share",
@@ -801,17 +828,25 @@
"create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user",
"create_workflow": "Create workflow",
"created": "Created",
"created_at": "Created",
"creating_linked_albums": "Creating linked albums...",
"crop": "Crop",
"crop_aspect_ratio_fixed": "Fixed",
"crop_aspect_ratio_free": "Free",
"crop_aspect_ratio_original": "Original",
"curated_object_page_title": "Things",
"current_device": "Current device",
"current_pin_code": "Current PIN code",
"current_server_address": "Current server address",
"custom_date": "Custom date",
"custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region",
"custom_url": "Custom URL",
"cutoff_date_description": "Remove photos and videos older than",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
@@ -867,6 +902,7 @@
"deselect_all": "Deselect All",
"details": "Details",
"direction": "Direction",
"disable": "Disable",
"disabled": "Disabled",
"disallow_edits": "Disallow edits",
"discord": "Discord",
@@ -929,11 +965,17 @@
"edit_tag": "Edit tag",
"edit_title": "Edit Title",
"edit_user": "Edit user",
"edit_workflow": "Edit workflow",
"editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
"editor_rotate_right": "Rotate 90° clockwise",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
@@ -1014,6 +1056,7 @@
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
"unable_to_connect": "Unable to connect",
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
"unable_to_create": "Unable to create workflow",
"unable_to_create_admin_account": "Unable to create admin account",
"unable_to_create_api_key": "Unable to create a new API Key",
"unable_to_create_library": "Unable to create library",
@@ -1024,6 +1067,7 @@
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
"unable_to_delete_workflow": "Unable to delete workflow",
"unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_empty_trash": "Unable to empty trash",
@@ -1063,6 +1107,7 @@
"unable_to_scan_library": "Unable to scan library",
"unable_to_set_feature_photo": "Unable to set feature photo",
"unable_to_set_profile_picture": "Unable to set profile picture",
"unable_to_set_rating": "Unable to set rating",
"unable_to_submit_job": "Unable to submit job",
"unable_to_trash_asset": "Unable to trash asset",
"unable_to_unlink_account": "Unable to unlink account",
@@ -1074,6 +1119,7 @@
"unable_to_update_settings": "Unable to update settings",
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
"unable_to_update_user": "Unable to update user",
"unable_to_update_workflow": "Unable to update workflow",
"unable_to_upload_file": "Unable to upload file"
},
"exclusion_pattern": "Exclusion pattern",
@@ -1120,14 +1166,17 @@
"features": "Features",
"features_in_development": "Features in Development",
"features_setting_description": "Manage the app features",
"file_name": "File name",
"file_name": "File name: {file_name}",
"file_name_or_extension": "File name or extension",
"file_size": "File size",
"filename": "Filename",
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_options": "Filter options",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
@@ -1137,12 +1186,16 @@
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward",
"free_up_space": "Free Up Space",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
"free_up_space_settings_subtitle": "Free up device storage",
"full_path": "Full path: {path}",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
"general": "General",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help",
"get_people_error": "Error getting people",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started",
"go_back": "Go back",
@@ -1175,6 +1228,7 @@
"hide_named_person": "Hide person {name}",
"hide_password": "Hide password",
"hide_person": "Hide person",
"hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
@@ -1247,8 +1301,12 @@
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs",
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep",
"keep_all": "Keep All",
"keep_favorites": "Keep favorites",
"keep_favorites_description": "Favorite assets will not be deleted from your device",
"keep_this_delete_others": "Keep this, delete others",
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
"keyboard_shortcuts": "Keyboard shortcuts",
@@ -1408,6 +1466,8 @@
"minimize": "Minimize",
"minute": "Minute",
"minutes": "Minutes",
"mirror_horizontal": "Horizontal",
"mirror_vertical": "Vertical",
"missing": "Missing",
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
@@ -1416,11 +1476,14 @@
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to",
"move_to_device_trash": "Move to device trash",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_up": "Move up",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash",
@@ -1430,6 +1493,7 @@
"my_albums": "My albums",
"name": "Name",
"name_or_nickname": "Name or nickname",
"name_required": "Name is required",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos",
@@ -1454,6 +1518,7 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1463,11 +1528,13 @@
"no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets",
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
"no_configuration_needed": "No configuration needed",
"no_devices": "No authorized devices",
"no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1563,6 +1630,7 @@
"people": "People",
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
"people_feature_description": "Browsing photos and videos grouped by people",
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
"people_sidebar_description": "Display a link to People in the sidebar",
"permanent_deletion_warning": "Permanent deletion warning",
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
@@ -1587,11 +1655,14 @@
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",
"person_selected": "Person selected",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
"photos": "Photos",
"photos_and_videos": "Photos & Videos",
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
"photos_from_previous_years": "Photos from previous years",
"photos_only": "Photos only",
"pick_a_location": "Pick a location",
"pick_custom_range": "Custom range",
"pick_date_range": "Select a date range",
@@ -1667,10 +1738,12 @@
"purchase_settings_server_activated": "The server product key is managed by the admin",
"query_asset_id": "Query Asset ID",
"queue_status": "Queuing {count}/{total}",
"rate_asset": "Rate Asset",
"rating": "Star rating",
"rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}",
"rating_description": "Display the EXIF rating in the info panel",
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
"reaction_options": "Reaction options",
"read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled",
@@ -1770,9 +1843,11 @@
"saved_settings": "Saved settings",
"say_something": "Say something",
"scaffold_body_error_occurred": "Error occurred",
"scan": "Scan",
"scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan",
"scan_settings": "Scan Settings",
"scanning": "Scanning",
"scanning_for_album": "Scanning for album...",
"search": "Search",
"search_albums": "Search albums",
@@ -1836,17 +1911,23 @@
"second": "Second",
"see_all_people": "See all people",
"select": "Select",
"select_album": "Select album",
"select_album_cover": "Select album cover",
"select_albums": "Select albums",
"select_all": "Select all",
"select_all_duplicates": "Select all duplicates",
"select_all_in": "Select all in {group}",
"select_avatar_color": "Select avatar color",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_cutoff_date": "Select cutoff date",
"select_face": "Select face",
"select_featured_photo": "Select featured photo",
"select_from_computer": "Select from computer",
"select_keep_all": "Select keep all",
"select_library_owner": "Select library owner",
"select_new_face": "Select new face",
"select_people": "Select people",
"select_person": "Select person",
"select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos",
"select_trash_all": "Select trash all",
@@ -1982,6 +2063,7 @@
"show_password": "Show password",
"show_person_options": "Show person options",
"show_progress_bar": "Show Progress Bar",
"show_schema": "Show schema",
"show_search_options": "Show search options",
"show_shared_links": "Show shared links",
"show_slideshow_transition": "Show slideshow transition",
@@ -2109,6 +2191,13 @@
"trash_page_select_assets_btn": "Select assets",
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow",
"trigger_person_recognized": "Person Recognized",
"trigger_person_recognized_description": "Triggered when a person is detected",
"trigger_type": "Trigger type",
"troubleshoot": "Troubleshoot",
"type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code",
@@ -2139,7 +2228,9 @@
"unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2185,6 +2276,7 @@
"utilities": "Utilities",
"validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL",
"validation_error": "Validation error",
"variables": "Variables",
"version": "Version",
"version_announcement_closing": "Your friend, Alex",
@@ -2196,6 +2288,7 @@
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
"videos": "Videos",
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
"videos_only": "Videos only",
"view": "View",
"view_album": "View Album",
"view_all": "View All",
@@ -2216,6 +2309,8 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"visual": "Visual",
"visual_builder": "Visual builder",
"waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning",
@@ -2224,13 +2319,26 @@
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"your_wifi_name": "Your Wi-Fi name",
"zero_to_clear_rating": "press 0 to clear asset rating",
"zoom_image": "Zoom Image",
"zoom_to_bounds": "Zoom to bounds"
}

13
i18n/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "immich-i18n",
"version": "1.0.0",
"private": true,
"scripts": {
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.7.4",
"prettier-plugin-sort-json": "^4.1.1"
}
}

View File

@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
FROM python:3.11-bookworm@sha256:667cf70698924920f29ebdb8d749ab665811503b87093d4f11826d114fd7255e AS builder-cpu
FROM builder-cpu AS builder-openvino
FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -22,20 +22,18 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.30.1/cmake-3.30.1-linux-x86_64.sh && \
chmod +x cmake-3.30.1-linux-x86_64.sh && \
mkdir -p /code/cmake-3.30.1-linux-x86_64 && \
./cmake-3.30.1-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.30.1-linux-x86_64 && \
rm cmake-3.30.1-linux-x86_64.sh
ENV PATH=/code/cmake-3.30.1-linux-x86_64/bin:${PATH}
RUN apt-get update && apt-get install -y --no-install-recommends wget git
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \
chmod +x cmake-3.31.9-linux-x86_64.sh && \
mkdir -p /code/cmake-3.31.9-linux-x86_64 && \
./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \
rm cmake-3.31.9-linux-x86_64.sh
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
WORKDIR /code/onnxruntime
@@ -45,9 +43,26 @@ COPY ./patches/* /tmp/
RUN git apply /tmp/*.patch
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
ENV CCACHE_DIR="/ccache"
# Note: the `parallel` setting uses a substantial amount of RAM
RUN ./build.sh --allow_running_as_root --config Release --build_wheel --update --build --parallel 17 --cmake_extra_defines\
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" --skip_tests --use_rocm --rocm_home=/opt/rocm
RUN --mount=type=cache,target=/ccache \
./build.sh \
--allow_running_as_root \
--config Release \
--build_wheel \
--update \
--build \
--parallel 17 \
--cmake_extra_defines \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
--skip_tests \
--use_rocm \
--rocm_home=/opt/rocm \
--use_cache \
--compile_no_warning_as_error
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
FROM builder-${DEVICE} AS builder
@@ -68,20 +83,23 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:917ec0e42cd6af87657a768449c2f604a6b67c7ab8e10ff917b8724799f816d3 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-core-2_2.24.8+20344_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-opencl-2_2.24.8+20344_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/intel-opencl-icd_25.48.36300.8-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libigdgmm12_22.8.2_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
@@ -102,7 +120,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm
FROM prod-cpu AS prod-armnn

View File

@@ -36,7 +36,7 @@ from .schemas import (
T,
)
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger
MultiPartParser.spool_max_size = 2**26 # spools to disk if payload is 64 MiB or larger
model_cache = ModelCache(revalidate=settings.model_ttl > 0)
thread_pool: ThreadPoolExecutor | None = None

View File

@@ -0,0 +1,33 @@
diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh
index bbb672a99e..0dc652fbda 100644
--- a/dockerfiles/scripts/install_common_deps.sh
+++ b/dockerfiles/scripts/install_common_deps.sh
@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \
curl \
libcurl4-openssl-dev \
libssl-dev \
- python3-dev
+ python3-dev \
+ ccache
# Dependencies: conda
-wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
rm ~/miniconda.sh
/opt/miniconda/bin/conda clean -ya
-pip install numpy
-pip install packaging
-pip install "wheel>=0.35.1"
+# Dependencies: venv and packages
+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv
+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip
+/opt/rocm-venv/bin/pip install --no-cache-dir \
+ "numpy==2.3.4" \
+ "packaging==25.0" \
+ "wheel==0.45.1" \
+ "setuptools==80.9.0"
+
rm -rf /opt/miniconda/pkgs
# Dependencies: cmake

View File

@@ -1,13 +0,0 @@
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index 2714e6f59..a69da76b4 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
else()
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
endif()
endif()

View File

@@ -3,7 +3,7 @@ name = "immich-ml"
version = "2.4.1"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0"
requires-python = ">=3.11,<4.0"
readme = "README.md"
dependencies = [
"aiocache>=0.12.1,<1.0",
@@ -12,7 +12,7 @@ dependencies = [
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
"numpy<2",
"numpy>=2.3.4",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=9.5.0,<11.0",
@@ -49,24 +49,16 @@ lint = [
dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies]
cpu = ["onnxruntime>=1.15.0,<2"]
cuda = ["onnxruntime-gpu>=1.17.0,<2"]
openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0"]
armnn = ["onnxruntime>=1.15.0,<2"]
rknn = ["onnxruntime>=1.15.0,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
cpu = ["onnxruntime>=1.23.2,<2"]
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = []
[tool.uv]
compile-bytecode = true
[[tool.uv.index]]
name = "cuda12"
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
explicit = true
[tool.uv.sources]
onnxruntime-gpu = { index = "cuda12" }
[tool.hatch.build.targets.sdist]
include = ["immich_ml"]

1975
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,7 @@ fi
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_SERVER(<\/string>)/\${1}$NEXT_SERVER\${2}/s" mobile/ios/Runner/Info.plist
./misc/release/archive-version.js "$NEXT_SERVER"

View File

@@ -1,9 +1,9 @@
experimental_monorepo_root = true
[tools]
node = "24.11.1"
node = "24.12.0"
flutter = "3.35.7"
pnpm = "10.24.0"
pnpm = "10.27.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"
@@ -34,4 +34,4 @@ run = { task = ":i18n:format-fix" }
[tasks."i18n:format-fix"]
dir = "i18n"
run = "pnpm dlx sort-json *.json"
run = "pnpm run format:fix"

View File

@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
!default.perspectivev3
fastlane/report.xml
Gemfile.lock
Gemfile.lock
certs/

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.2.1</string>
<string>2.4.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -44,7 +44,7 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "")
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app)
@@ -54,7 +54,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
profile_name: profile_name_main,
targets: ["Runner"]
)
@@ -65,7 +65,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
profile_name: profile_name_share,
targets: ["ShareExtension"]
)
@@ -76,7 +76,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
@@ -87,7 +87,10 @@ end
bundle_id_suffix: "",
configuration: "Release",
distribute_external: true,
version_number: nil
version_number: nil,
profile_name_main:,
profile_name_share:,
profile_name_widget:
)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
@@ -115,9 +118,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => "#{app_identifier} AppStore",
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
"#{app_identifier}" => profile_name_main,
"#{app_identifier}.ShareExtension" => profile_name_share,
"#{app_identifier}.Widget" => profile_name_widget
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -136,20 +139,35 @@ end
lane :gha_testflight_dev do
api_key = get_api_key
# Install development provisioning profiles
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload
build_and_upload(
api_key: api_key,
bundle_id_suffix: "development",
configuration: "Profile",
distribute_external: false
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -157,20 +175,33 @@ end
lane :gha_release_prod do
api_key = get_api_key
# Install provisioning profiles
install_provisioning_profile(path: "profile.mobileprovision")
install_provisioning_profile(path: "profile_share.mobileprovision")
install_provisioning_profile(path: "profile_widget.mobileprovision")
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for production bundle IDs
configure_code_signing
configure_code_signing(
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload with version number
build_and_upload(
api_key: api_key,
version_number: get_version_from_pubspec,
distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
end
@@ -215,13 +246,26 @@ end
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
# Install provisioning profiles (use development profiles for PR builds)
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build the app (same as gha_testflight_dev but without upload)
build_app(
@@ -233,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY

View File

@@ -7,3 +7,7 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum SortUserBy { id }
enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }

View File

@@ -4,7 +4,6 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
@@ -99,9 +98,7 @@ class AssetService {
height = fetched?.height?.toDouble();
}
final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
return (width: width, height: height, isFlipped: false);
}
Future<List<(String, String)>> getPlaces(String userId) {

View File

@@ -360,6 +360,7 @@ extension on Iterable<PlatformAlbum> {
name: e.name,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
assetCount: e.assetCount,
isIosSharedAlbum: e.isCloud,
),
).toList();
}

View File

@@ -79,6 +79,9 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(String userId, LatLngBounds bounds) =>
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
}

View File

@@ -33,6 +33,7 @@ extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
assetCount: assetCount,
backupSelection: backupSelection,
linkedRemoteAlbumId: linkedRemoteAlbumId,
isIosSharedAlbum: isIosSharedAlbum,
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
@@ -126,4 +127,49 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
return result;
}
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
}) async {
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true));
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
]);
Expression<bool> whereClause =
_db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate) &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.deletedAt.isNull();
// Exclude assets that are in iOS shared albums
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
}
if (keepFavorites) {
whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false);
}
query.where(whereClause);
final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
}
}

View File

@@ -22,6 +22,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
@@ -194,6 +195,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
);
batch.insert(
@@ -245,10 +248,21 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.batch((batch) {
for (final exif in data) {
int? width;
int? height;
if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) {
width = exif.exifImageHeight;
height = exif.exifImageWidth;
} else {
width = exif.exifImageWidth;
height = exif.exifImageHeight;
}
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
RemoteAssetEntityCompanion(width: Value(width), height: Value(height)),
where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(),
);
}
});

View File

@@ -253,6 +253,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin,
);
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final Map<DateTime, int> bucketCounts = {};
for (final asset in sorted) {
final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day);
bucketCounts[date] = (bucketCounts[date] ?? 0) + 1;
}
final buckets = bucketCounts.entries.map((e) => TimeBucket(date: e.key, assetCount: e.value)).toList();
return (
bucketSource: () => Stream.value(buckets),
assetSource: (offset, count) => Future.value(sorted.skip(offset).take(count).toList(growable: false)),
origin: origin,
);
}
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),

View File

@@ -42,6 +42,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
@@ -252,6 +253,13 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
themeMode: ref.watch(immichThemeModeProvider),
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
builder: (context, child) => ImmichTranslationProvider(
translations: ImmichTranslations(
submit: "submit".t(context: context),
password: "password".t(context: context),
),
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
),
routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref)],

View File

@@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart';
import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart';
import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_mobile/widgets/settings/notification_setting.dart';
@@ -22,6 +23,7 @@ enum SettingSection {
advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"),
assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"),
backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"),
freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"),
languages('language', Icons.language, "setting_languages_subtitle"),
networking('networking_settings', Icons.wifi, "networking_subtitle"),
notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"),
@@ -38,6 +40,7 @@ enum SettingSection {
SettingSection.assetViewer => const AssetViewerSettings(),
SettingSection.backup =>
Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(),
SettingSection.freeUpSpace => const FreeUpSpaceSettings(),
SettingSection.languages => const LanguageSettings(),
SettingSection.networking => const NetworkingSettings(),
SettingSection.notifications => const NotificationSetting(),

View File

@@ -370,6 +370,7 @@ class _MapWithMarker extends StatelessWidget {
? PositionedAssetMarkerIcon(
point: value.point,
assetRemoteId: value.marker.assetRemoteId,
assetThumbhash: '',
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
onTap: onMarkerTapped,
)

View File

@@ -0,0 +1,42 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@RoutePage()
class CleanupPreviewPage extends StatelessWidget {
final List<LocalAsset> assets;
const CleanupPreviewPage({super.key, required this.assets});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})),
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: context.colorScheme.surface,
),
body: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.fromAssetsWithBuckets(assets.cast<BaseAsset>(), TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true),
),
);
}
}

View File

@@ -19,6 +19,17 @@ List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color)
return children;
}
class _ComponentTitle extends StatelessWidget {
final String title;
const _ComponentTitle(this.title);
@override
Widget build(BuildContext context) {
return Text(title, style: context.textTheme.titleLarge);
}
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@@ -35,13 +46,51 @@ class ImmichUIShowcasePage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("IconButton", style: context.textTheme.titleLarge),
const _ComponentTitle("IconButton"),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("CloseButton"),
..._showcaseBuilder(
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("TextButton"),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
loading: true,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
loading: true,
),
const _ComponentTitle("Form"),
ImmichForm(
onSubmit: () {},
child: const Column(
spacing: 10,
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
),
),
Text("CloseButton", style: context.textTheme.titleLarge),
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
],
),
),

View File

@@ -171,67 +171,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
unawaited(context.pushRoute(DriftActivitiesRoute(album: _album)));
}
Future<void> showOptionSheet(BuildContext context) async {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
final canAddPhotos =
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
unawaited(
showModalBottomSheet(
context: context,
backgroundColor: context.colorScheme.surface,
isScrollControlled: false,
builder: (context) {
return DriftRemoteAlbumOption(
onDeleteAlbum: isOwner
? () async {
await deleteAlbum(context);
if (context.mounted) {
context.pop();
}
}
: null,
onAddUsers: isOwner
? () async {
await addUsers(context);
context.pop();
}
: null,
onAddPhotos: isOwner || canAddPhotos
? () async {
await addAssets(context);
context.pop();
}
: null,
onToggleAlbumOrder: isOwner
? () async {
await toggleAlbumOrder();
context.pop();
}
: null,
onEditAlbum: isOwner
? () async {
context.pop();
await showEditTitleAndDescription(context);
}
: null,
onCreateSharedLink: isOwner
? () async {
context.pop();
unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id)));
}
: null,
onShowOptions: () {
context.pop();
context.pushRoute(DriftAlbumOptionsRoute(album: _album));
},
);
},
),
);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
@@ -249,8 +188,16 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
child: Timeline(
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
kebabMenu: _AlbumKebabMenu(
album: _album,
onDeleteAlbum: () => deleteAlbum(context),
onAddUsers: () => addUsers(context),
onAddPhotos: () => addAssets(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditAlbum: () => showEditTitleAndDescription(context),
onCreateSharedLink: () => unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id))),
onShowOptions: () => context.pushRoute(DriftAlbumOptionsRoute(album: _album)),
),
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onActivity: () => showActivity(context),
),
@@ -414,3 +361,77 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
);
}
}
class _AlbumKebabMenu extends ConsumerWidget {
final RemoteAlbum album;
final VoidCallback? onDeleteAlbum;
final VoidCallback? onAddUsers;
final VoidCallback? onAddPhotos;
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onCreateSharedLink;
final VoidCallback? onShowOptions;
const _AlbumKebabMenu({
required this.album,
this.onDeleteAlbum,
this.onAddUsers,
this.onAddPhotos,
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onCreateSharedLink,
this.onShowOptions,
});
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
final iconColor = Color.lerp(Colors.white, context.primaryColor, scrollProgress);
final iconShadows = [
if (scrollProgress < 0.95)
Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
else
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
final user = ref.watch(currentUserProvider);
final isOwner = user != null && user.id == album.ownerId;
return FutureBuilder<bool>(
future: ref
.read(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {
final canAddPhotos = snapshot.data ?? false;
return DriftRemoteAlbumOption(
iconColor: iconColor,
iconShadows: iconShadows,
onDeleteAlbum: isOwner ? onDeleteAlbum : null,
onAddUsers: isOwner ? onAddUsers : null,
onAddPhotos: isOwner || canAddPhotos ? onAddPhotos : null,
onToggleAlbumOrder: isOwner ? onToggleAlbumOrder : null,
onEditAlbum: isOwner ? onEditAlbum : null,
onCreateSharedLink: isOwner ? onCreateSharedLink : null,
onShowOptions: onShowOptions,
);
},
);
}
}

View File

@@ -37,7 +37,7 @@ class DriftCropImagePage extends HookWidget {
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onTap: () async {
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
@@ -79,13 +79,13 @@ class DriftCropImagePage extends HookWidget {
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateLeft(),
onPressed: () => cropController.rotateLeft(),
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateRight(),
onPressed: () => cropController.rotateRight(),
),
],
),

View File

@@ -53,7 +53,7 @@ class BaseActionButton extends ConsumerWidget {
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
);
}

View File

@@ -92,6 +92,8 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
// Hide controls by default for videos and motion photos
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
}
@@ -525,7 +527,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
ref.read(assetViewerProvider.notifier).setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
}
@@ -603,6 +611,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
disableScaleGestures: true,
child: SizedBox(
width: ctx.width,
height: ctx.height,

View File

@@ -68,7 +68,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
return const SizedBox.shrink();
}
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
final remoteAsset = asset as RemoteAsset;
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
@@ -92,7 +92,12 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
ExifMap(
exifInfo: exifInfo!,
markerId: remoteAsset.id,
markerAssetThumbhash: remoteAsset.thumbHash,
onMapCreated: _onMapCreated,
),
const SizedBox(height: 16),
if (locationName != null)
Padding(

View File

@@ -119,7 +119,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
@@ -93,7 +94,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
uri: getPreviewUrlForRemoteId(key.assetId),
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview),
headers: headers,
cacheManager: cacheManager,
);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class DriftRemoteAlbumOption extends ConsumerWidget {
const DriftRemoteAlbumOption({
@@ -14,6 +15,8 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onShowOptions,
this.iconColor,
this.iconShadows,
});
final VoidCallback? onAddPhotos;
@@ -24,73 +27,131 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onShowOptions;
final Color? iconColor;
final List<Shadow>? iconShadows;
@override
Widget build(BuildContext context, WidgetRef ref) {
TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
final theme = context.themeData;
final menuChildren = <Widget>[];
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: ListView(
shrinkWrap: true,
children: [
if (onEditAlbum != null)
ListTile(
leading: const Icon(Icons.edit),
title: Text('edit_album'.t(context: context), style: textStyle),
onTap: onEditAlbum,
),
if (onAddPhotos != null)
ListTile(
leading: const Icon(Icons.add_a_photo),
title: Text('add_photos'.t(context: context), style: textStyle),
onTap: onAddPhotos,
),
if (onAddUsers != null)
ListTile(
leading: const Icon(Icons.group_add),
title: Text('album_viewer_page_share_add_users'.t(context: context), style: textStyle),
onTap: onAddUsers,
),
if (onLeaveAlbum != null)
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: Text('leave_album'.t(context: context), style: textStyle),
onTap: onLeaveAlbum,
),
if (onToggleAlbumOrder != null)
ListTile(
leading: const Icon(Icons.swap_vert_rounded),
title: Text('change_display_order'.t(context: context), style: textStyle),
onTap: onToggleAlbumOrder,
),
if (onCreateSharedLink != null)
ListTile(
leading: const Icon(Icons.link),
title: Text('create_shared_link'.t(context: context), style: textStyle),
onTap: onCreateSharedLink,
),
if (onShowOptions != null)
ListTile(
leading: const Icon(Icons.settings),
title: Text('options'.t(context: context), style: textStyle),
onTap: onShowOptions,
),
if (onDeleteAlbum != null) ...[
const Divider(indent: 16, endIndent: 16),
ListTile(
leading: Icon(Icons.delete, color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
title: Text(
'delete_album'.t(context: context),
style: textStyle.copyWith(color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
),
onTap: onDeleteAlbum,
),
],
],
if (onEditAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'edit_album'.t(context: context),
iconData: Icons.edit,
onPressed: onEditAlbum,
menuItem: true,
),
);
}
if (onAddPhotos != null) {
menuChildren.add(
BaseActionButton(
label: 'add_photos'.t(context: context),
iconData: Icons.add_a_photo,
onPressed: onAddPhotos,
menuItem: true,
),
);
}
if (onAddUsers != null) {
menuChildren.add(
BaseActionButton(
label: 'album_viewer_page_share_add_users'.t(context: context),
iconData: Icons.group_add,
onPressed: onAddUsers,
menuItem: true,
),
);
}
if (onLeaveAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'leave_album'.t(context: context),
iconData: Icons.person_remove_rounded,
onPressed: onLeaveAlbum,
menuItem: true,
),
);
}
if (onToggleAlbumOrder != null) {
menuChildren.add(
BaseActionButton(
label: 'change_display_order'.t(context: context),
iconData: Icons.swap_vert_rounded,
onPressed: onToggleAlbumOrder,
menuItem: true,
),
);
}
if (onCreateSharedLink != null) {
menuChildren.add(
BaseActionButton(
label: 'create_shared_link'.t(context: context),
iconData: Icons.link,
onPressed: onCreateSharedLink,
menuItem: true,
),
);
}
if (onShowOptions != null) {
menuChildren.add(
BaseActionButton(
label: 'options'.t(context: context),
iconData: Icons.settings,
onPressed: onShowOptions,
menuItem: true,
),
);
}
if (onDeleteAlbum != null) {
menuChildren.add(const Divider(height: 1));
menuChildren.add(
BaseActionButton(
label: 'delete_album'.t(context: context),
iconData: Icons.delete,
iconColor: context.isDarkTheme ? Colors.red[400] : Colors.red[800],
onPressed: onDeleteAlbum,
menuItem: true,
),
);
}
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: Icon(Icons.more_vert_rounded, color: iconColor ?? Colors.white, shadows: iconShadows),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View File

@@ -42,6 +42,7 @@ class Timeline extends StatelessWidget {
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
});
final Widget? topSliverWidget;
@@ -54,6 +55,7 @@ class Timeline extends StatelessWidget {
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
@override
Widget build(BuildContext context) {
@@ -73,6 +75,7 @@ class Timeline extends StatelessWidget {
groupBy: groupBy,
),
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
@@ -89,6 +92,17 @@ class Timeline extends StatelessWidget {
}
}
class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
@override
bool build() => true;
@override
void setReadonlyMode(bool value) {}
@override
void toggleReadonlyMode() {}
}
class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,

View File

@@ -5,16 +5,21 @@ import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
final syncStatusNotifier = ref.read(syncStatusProvider.notifier);
final backupProvider = ref.read(driftBackupProvider.notifier);
final manager = BackgroundSyncManager(
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
backupProvider.updateError(BackupError.none);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(BackupError.none);
}
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
}
},
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
onLocalSyncStart: syncStatusNotifier.startLocalSync,

View File

@@ -212,8 +212,8 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
),
) {
{
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
_statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
}
}
@@ -224,6 +224,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (!mounted) {
_logger.warning("Skip _removeUploadItem: notifier disposed");
return;
}
if (state.uploadItems.containsKey(taskId)) {
final updatedItems = Map<String, DriftUploadStatus>.from(state.uploadItems);
updatedItems.remove(taskId);
@@ -232,6 +236,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskStatusUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
switch (update.status) {
@@ -291,6 +299,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskProgressUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
final filename = update.task.displayName;
final progress = update.progress;
@@ -332,7 +344,15 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> getBackupStatus(String userId) async {
if (!mounted) {
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
return;
}
final counts = await _uploadService.getBackupCounts(userId);
if (!mounted) {
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
return;
}
state = state.copyWith(
totalCount: counts.total,
@@ -343,6 +363,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void updateError(BackupError error) async {
if (!mounted) {
_logger.warning("Skip updateError: notifier disposed");
return;
}
state = state.copyWith(error: error);
}
@@ -360,10 +384,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> cancel() async {
if (!mounted) {
_logger.warning("Skip cancel (pre-call): notifier disposed");
return;
}
dPrint(() => "Canceling backup tasks...");
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
final activeTaskCount = await _uploadService.cancelBackup();
if (!mounted) {
_logger.warning("Skip cancel (post-call): notifier disposed");
return;
}
if (activeTaskCount > 0) {
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
@@ -376,9 +408,17 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> handleBackupResume(String userId) async {
if (!mounted) {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
state = state.copyWith(error: BackupError.none);
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) {

View File

@@ -0,0 +1,106 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
class CleanupState {
final DateTime? selectedDate;
final List<LocalAsset> assetsToDelete;
final bool isScanning;
final bool isDeleting;
final AssetFilterType filterType;
final bool keepFavorites;
const CleanupState({
this.selectedDate,
this.assetsToDelete = const [],
this.isScanning = false,
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepFavorites = true,
});
CleanupState copyWith({
DateTime? selectedDate,
List<LocalAsset>? assetsToDelete,
bool? isScanning,
bool? isDeleting,
AssetFilterType? filterType,
bool? keepFavorites,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
isScanning: isScanning ?? this.isScanning,
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepFavorites: keepFavorites ?? this.keepFavorites,
);
}
}
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
});
class CleanupNotifier extends StateNotifier<CleanupState> {
final CleanupService _cleanupService;
final String? _userId;
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
void setSelectedDate(DateTime? date) {
state = state.copyWith(selectedDate: date, assetsToDelete: []);
}
void setFilterType(AssetFilterType filterType) {
state = state.copyWith(filterType: filterType, assetsToDelete: []);
}
void setKeepFavorites(bool keepFavorites) {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
}
Future<void> scanAssets() async {
if (_userId == null || state.selectedDate == null) {
return;
}
state = state.copyWith(isScanning: true);
try {
final assets = await _cleanupService.getRemovalCandidates(
_userId,
state.selectedDate!,
filterType: state.filterType,
keepFavorites: state.keepFavorites,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
} catch (e) {
state = state.copyWith(isScanning: false);
rethrow;
}
}
Future<int> deleteAssets() async {
if (state.assetsToDelete.isEmpty) {
return 0;
}
state = state.copyWith(isDeleting: true);
try {
final deletedCount = await _cleanupService.deleteLocalAssets(state.assetsToDelete.map((a) => a.id).toList());
state = state.copyWith(assetsToDelete: [], isDeleting: false);
return deletedCount;
} catch (e) {
state = state.copyWith(isDeleting: false);
rethrow;
}
}
void reset() {
state = const CleanupState();
}
}

View File

@@ -88,6 +88,7 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart';
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
@@ -338,6 +339,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -611,6 +611,43 @@ class ChangePasswordRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [CleanupPreviewPage]
class CleanupPreviewRoute extends PageRouteInfo<CleanupPreviewRouteArgs> {
CleanupPreviewRoute({
Key? key,
required List<LocalAsset> assets,
List<PageRouteInfo>? children,
}) : super(
CleanupPreviewRoute.name,
args: CleanupPreviewRouteArgs(key: key, assets: assets),
initialChildren: children,
);
static const String name = 'CleanupPreviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<CleanupPreviewRouteArgs>();
return CleanupPreviewPage(key: args.key, assets: args.assets);
},
);
}
class CleanupPreviewRouteArgs {
const CleanupPreviewRouteArgs({this.key, required this.assets});
final Key? key;
final List<LocalAsset> assets;
@override
String toString() {
return 'CleanupPreviewRouteArgs{key: $key, assets: $assets}';
}
}
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {

View File

@@ -0,0 +1,45 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
final cleanupServiceProvider = Provider<CleanupService>((ref) {
return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider));
});
class CleanupService {
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepFavorites: keepFavorites,
);
}
Future<int> deleteLocalAssets(List<String> localIds) async {
if (localIds.isEmpty) {
return 0;
}
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
return 0;
}
}

View File

@@ -1,4 +1,3 @@
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@@ -10,14 +9,18 @@ String getThumbnailUrl(final Asset asset, {AssetMediaSize type = AssetMediaSize.
}
String getThumbnailCacheKey(final Asset asset, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, asset.thumbhash!, type: type);
}
String getThumbnailCacheKeyForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
String getThumbnailCacheKeyForRemoteId(
final String id,
final String thumbhash, {
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
if (type == AssetMediaSize.thumbnail) {
return 'thumbnail-image-$id';
return 'thumbnail-image-$id-$thumbhash';
} else {
return '${id}_previewStage';
return '${id}_${thumbhash}_previewStage';
}
}
@@ -32,26 +35,25 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return getThumbnailCacheKeyForRemoteId(album.thumbnail.value!.remoteId!, type: type);
return getThumbnailCacheKeyForRemoteId(
album.thumbnail.value!.remoteId!,
album.thumbnail.value!.thumbhash!,
type: type,
);
}
String getOriginalUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original';
String getOriginalUrlForRemoteId(final String id, {bool edited = true}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original?edited=$edited';
}
String getImageCacheKey(final Asset asset) {
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromDto = asset.id == noDbId;
return '${isFromDto ? asset.remoteId : asset.id}_fullStage';
String getThumbnailUrlForRemoteId(
final String id, {
AssetMediaSize type = AssetMediaSize.thumbnail,
bool edited = true,
}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
}
String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
}
String getPreviewUrlForRemoteId(final String id) =>
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}';
String getPlaybackUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
}

View File

@@ -31,7 +31,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 19;
const int targetVersion = 20;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -86,6 +86,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
}
if (version < 20 && Store.isBetaTimelineEnabled) {
await _syncLocalAlbumIsIosSharedAlbum(drift);
}
if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion);
return;
@@ -258,6 +262,25 @@ Future<bool> _populateLocalAssetTime(Drift db) async {
}
}
Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
await db.batch((batch) {
for (final album in albums) {
batch.update(
db.localAlbumEntity,
LocalAlbumEntityCompanion(isIosSharedAlbum: Value(album.isCloud)),
where: (t) => t.id.equals(album.id),
);
}
});
dPrint(() => "[MIGRATION] Successfully updated isIosSharedAlbum for ${albums.length} albums");
} catch (error) {
dPrint(() => "[MIGRATION] Error while syncing local album isIosSharedAlbum: $error");
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();

View File

@@ -74,7 +74,7 @@ class AssetLocation extends HookConsumerWidget {
],
),
asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId, markerAssetThumbhash: asset.thumbhash),
const SizedBox(height: 16),
getLocationName(),
Text(

View File

@@ -10,10 +10,20 @@ import 'package:url_launcher/url_launcher.dart';
class ExifMap extends StatelessWidget {
final ExifInfo exifInfo;
// TODO: Pass in a BaseAsset instead of the ID and thumbhash when removing old timeline
// This is currently structured this way because of the old timeline implementation
// reusing this component
final String? markerId;
final String? markerAssetThumbhash;
final MapCreatedCallback? onMapCreated;
const ExifMap({super.key, required this.exifInfo, this.markerId = 'marker', this.onMapCreated});
const ExifMap({
super.key,
required this.exifInfo,
this.markerAssetThumbhash,
this.markerId = 'marker',
this.onMapCreated,
});
@override
Widget build(BuildContext context) {
@@ -61,6 +71,7 @@ class ExifMap extends StatelessWidget {
width: constraints.maxWidth,
zoom: 12.0,
assetMarkerRemoteId: markerId,
assetThumbhash: markerAssetThumbhash,
onTap: (tapPosition, latLong) async {
Uri? uri = await createCoordinatesUri();

View File

@@ -53,6 +53,7 @@ class BackupInfoCard extends StatelessWidget {
info,
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
if (isLoading)

View File

@@ -4,6 +4,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -41,6 +42,13 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
return Icon(Icons.circle, color: context.colorScheme.surfaceContainerHighest);
}
Widget buildSubtitle() {
return Text(
album.isIosSharedAlbum ? '${album.assetCount} (iCloud Shared Album)' : album.assetCount.toString(),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
}
return GestureDetector(
onDoubleTap: () {
ref.watch(hapticFeedbackProvider.notifier).selectionClick();
@@ -73,8 +81,8 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
}
},
leading: buildIcon(),
title: Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
subtitle: Text(album.assetCount.toString()),
title: Text(album.name, style: context.textTheme.titleSmall),
subtitle: buildSubtitle(),
trailing: IconButton(
onPressed: () {
context.pushRoute(LocalTimelineRoute(album: album));

View File

@@ -24,15 +24,13 @@ class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
const RemoteAlbumSliverAppBar({
super.key,
this.icon = Icons.camera,
this.onShowOptions,
this.onToggleAlbumOrder,
required this.kebabMenu,
this.onEditTitle,
this.onActivity,
});
final IconData icon;
final void Function()? onShowOptions;
final void Function()? onToggleAlbumOrder;
final Widget kebabMenu;
final void Function()? onEditTitle;
final void Function()? onActivity;
@@ -91,21 +89,12 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
onPressed: () => context.maybePop(),
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
widget.kebabMenu,
],
title: Builder(
builder: (context) {

View File

@@ -14,6 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -29,12 +30,7 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -45,16 +41,33 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) {
if (url == null || url.isEmpty) return null;
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateEmail(String? email) {
if (email == null || email == '') return null;
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
if (email.contains(' ') || !email.contains('@')) {
return 'login_form_err_invalid_email'.tr();
}
return null;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode();
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final isPasswordLoginEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
@@ -96,7 +109,6 @@ class LoginForm extends HookConsumerWidget {
}
try {
isLoadingServer.value = true;
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
// Fetch and load server config and features
@@ -120,7 +132,6 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} on HandshakeException {
ImmichToast.show(
context: context,
@@ -130,7 +141,6 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} catch (e) {
ImmichToast.show(
context: context,
@@ -140,10 +150,7 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
}
isLoadingServer.value = false;
}
useEffect(() {
@@ -230,8 +237,6 @@ class LoginForm extends HookConsumerWidget {
login() async {
TextInput.finishAutofillContext();
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
@@ -261,8 +266,6 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
}
@@ -306,8 +309,6 @@ class LoginForm extends HookConsumerWidget {
codeChallenge,
);
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
} catch (error, stack) {
@@ -319,7 +320,6 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
return;
}
@@ -338,7 +338,6 @@ class LoginForm extends HookConsumerWidget {
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
isLoading.value = false;
final permission = ref.watch(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
@@ -364,9 +363,7 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
} finally {}
} else {
ImmichToast.show(
context: context,
@@ -374,66 +371,10 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.info,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
return;
}
}
buildSelectServer() {
const buttonRadius = 25.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: getServerAuthSettings,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(buttonRadius),
bottomLeft: Radius.circular(buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(buttonRadius),
bottomRight: Radius.circular(buttonRadius),
),
),
),
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoadingServer.value) const LoadingIcon(),
],
);
}
buildVersionCompatWarning() {
checkVersionMismatch();
@@ -455,66 +396,102 @@ class LoginForm extends HookConsumerWidget {
);
}
buildLogin() {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildVersionCompatWarning(),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
final serverSelectionOrLogin = serverEndpoint.value == null
? Padding(
padding: const EdgeInsets.only(top: ImmichSpacing.md),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
ImmichForm(
submitText: 'next'.t(context: context),
submitIcon: Icons.arrow_forward_rounded,
onSubmit: getServerAuthSettings,
child: ImmichTextInput(
controller: serverEndpointController,
label: 'login_form_endpoint_url'.t(context: context),
hintText: 'login_form_endpoint_hint'.t(context: context),
validator: _validateUrl,
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
),
ImmichTextButton(
labelText: 'settings'.t(context: context),
icon: Icons.settings,
variant: ImmichVariant.ghost,
onPressed: () => context.pushRoute(const SettingsRoute()),
),
],
),
if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
],
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
if (isPasswordLoginEnable.value)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
)
: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
buildVersionCompatWarning(),
Padding(
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
child: Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
),
if (isPasswordLoginEnable.value)
ImmichForm(
submitText: 'login'.t(context: context),
submitIcon: Icons.login_rounded,
onSubmit: login,
child: Column(
spacing: ImmichSpacing.md,
children: [
ImmichTextInput(
controller: emailController,
label: 'email'.t(context: context),
hintText: 'login_form_email_hint'.t(context: context),
validator: _validateEmail,
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
onSubmit: (_, _) => passwordFocusNode.requestFocus(),
),
ImmichPasswordInput(
controller: passwordController,
focusNode: passwordFocusNode,
label: 'password'.t(context: context),
hintText: 'login_form_password_hint'.t(context: context),
keyboardAction: TextInputAction.go,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
],
],
),
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('back').tr(),
if (isOauthEnable.value)
ImmichForm(
submitText: oAuthButtonLabel.value,
submitIcon: Icons.pin_outlined,
onSubmit: oAuthLogin,
child: isPasswordLoginEnable.value
? Padding(
padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5),
)
: const SizedBox.shrink(),
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value)
Center(child: const Text('login_disabled').tr()),
ImmichTextButton(
labelText: 'back'.t(context: context),
icon: Icons.arrow_back,
variant: ImmichVariant.ghost,
onPressed: () => serverEndpoint.value = null,
),
],
),
],
),
);
}
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
);
return LayoutBuilder(
builder: (context, constraints) {

View File

@@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class OAuthLoginButton extends ConsumerWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final String buttonLabel;
final Function() onPressed;
const OAuthLoginButton({
super.key,
required this.serverEndpointController,
required this.isLoading,
required this.buttonLabel,
required this.onPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: context.primaryColor.withAlpha(230),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: onPressed,
icon: const Icon(Icons.pin_rounded),
label: Text(buttonLabel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
);
}
}

View File

@@ -1,37 +0,0 @@
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';
class PasswordInput extends HookConsumerWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPasswordVisible = useState<bool>(false);
return TextFormField(
obscureText: !isPasswordVisible.value,
controller: controller,
decoration: InputDecoration(
labelText: 'password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
suffixIcon: IconButton(
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
textInputAction: TextInputAction.go,
);
}
}

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