Compare commits

...

109 Commits

Author SHA1 Message Date
midzelis
21d2ce859a refactor: create DisplayMetrics so overlays work with object-fit or explicit positions 2026-02-25 18:56:31 +00:00
Mees Frensel
55e625a2ac fix(web): error page i18n (#26517) 2026-02-25 18:35:25 +01:00
Daniel Dietzler
ca6c486a80 refactor: person stubs (#26512) 2026-02-25 08:56:00 -05:00
socksprox
d94d9600a7 fix(mobile): birthday picker shows limited months when no date exists (#26407)
* ScrollDatePicker defaults maximumDate to DateTime.now(). When no birthday exists, the picker starts at today (Feb 2026) with max also Feb 2026 — so only Jan–Feb are available for the current year.

Fix applied: Added maximumDate: DateTime(DateTime.now().year, 12, 31) at person_edit_birthday_modal.widget.dart:93, allowing all 12 months to be selected while still preventing future-year birthdays.

* fix(mobile): initialize birthday picker to past date to prevent future birthdays

When no birthday exists, initialize to 30 years ago instead of today.
This allows all 12 months to be selectable while keeping maximumDate
as DateTime.now() to prevent future birthday selection.

Fixes issue where only current months were available due to maxDate constraint.

---------

Co-authored-by: socksprox <info@shadowfly.net>
2026-02-25 07:58:02 +05:30
Mees Frensel
11e5c42bc9 fix(web): toast warning when trying to upload unsupported file type (#26492) 2026-02-24 15:58:40 -05:00
shenlong
33c6cf8325 test: backup repository (#26494)
test: backup repository tests

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-24 15:50:02 -05:00
renovate[bot]
dd97395f3a chore(deps): update dependency gunicorn to v25 (#26486)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 16:14:04 +00:00
renovate[bot]
7ae268e287 fix(deps): update dependency exiftool-vendored to v35 (#26488)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-02-24 14:40:57 +01:00
Jason Rasmussen
f07e2b58f0 refactor: prefer buffer (#26469)
* refactor: prefer buffer

* Update server/src/schema/tables/session.table.ts

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

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-02-24 13:26:36 +00:00
shenlong
4b8f90aa55 refactor: remote album repository test to use context (#26481)
* refactor: remote album repository test to use context

* refactor: medium repo context (#26482)

* refactor: medium repo context

* store userId in closure

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-24 13:25:07 +00:00
Daniel Dietzler
55ee9f76da chore: eslint 10 (#26490) 2026-02-24 08:24:18 -05:00
Michel Heusschen
30f6d4439e fix(web): prevent null folder tree on concurrent load (#26489) 2026-02-24 08:23:07 -05:00
renovate[bot]
f62d98a0d1 chore(deps): update dependency eslint-plugin-unicorn to v63 (#26484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 12:34:12 +01:00
renovate[bot]
db3d580761 chore(deps): update dependency globals to v17 (#26485)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 12:18:01 +01:00
renovate[bot]
0bc38fefe6 fix(deps): update typescript-projects (#26483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-02-24 11:15:26 +00:00
renovate[bot]
acc4219849 chore(deps): update actions/checkout action to v6.0.2 (#26477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 10:09:34 +01:00
shenlong
5234e21241 fix: retain asset when either asset is a favorite (#26473)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-23 16:52:34 -05:00
shenlong
17b327bfcd refactor: medium repository context (#26472)
refactor: repository test context

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-23 21:34:21 +00:00
Min Idzelis
d14d0a9b9b feat: add isTransparent to db (#26413) 2026-02-23 21:33:52 +00:00
Mees Frensel
bf47147fbb fix(server): accept showAt and hideAt for creating memories (#26429)
* fix(server): accept showAt and hideAt for creating memories

* fix history
2026-02-23 21:26:34 +00:00
aviv926
9ea0a69a72 feat(docs): Adding information about parameter c= (#26430)
* Adding information about parameter c=

* Apply suggestions from code review

Co-authored-by: bo0tzz <git@bo0tzz.me>

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2026-02-23 21:21:06 +00:00
shenlong
00f43ffc25 chore: add Option type (#26467)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-23 16:20:25 -05:00
Jonathan Jogenfors
96dc4a77a0 fix: always show library scan button (#26428)
* fix: always show library scan button

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-23 21:18:23 +00:00
shenlong
db7158b967 refactor: ImmichHtmlText to ImmichFormattedText (#26466)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-23 16:05:00 -05:00
Brandon Wees
e5722c525b feat: getAssetEdits respond with edit IDs (#26445)
* feat: getAssetEdits respond with edit IDs

* chore: cleanup typings for edit API

* chore: cleanup types with jason

* fix: openapi sync

* fix: factory
2026-02-23 20:57:57 +00:00
shenlong
f616de5af8 chore(mobile): nudge users to switch to the new timeline (#26458)
* nudge users to switch to the new timeline

* remove timeline switch setting from new timeline

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-23 13:42:32 -06:00
shenlong
4f39663d27 fix: simplify timeline rebuild on orientation (#26408)
* revert: current fix

# Conflicts:
#	mobile/lib/presentation/widgets/timeline/timeline.widget.dart

* fix: simpler fix

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-23 13:30:09 -05:00
Thomas
367025a3a8 chore(mobile): simplify showing details toggle (#26403)
Keeping track of the last scroll offset and guarding on scroll direction
is not necessary. The dead zone with kTouchSlop is more than sufficient,
and much simpler.

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2026-02-23 23:49:35 +05:30
Min Idzelis
60dafecdc9 refactor: thumbnail components (#26379) 2026-02-23 11:56:20 -05:00
Yaros
16c1c3c780 fix(mobile): join local on archived timeline (#26387) 2026-02-23 20:21:32 +05:30
Brandon Wees
e633bc3f24 fix: missing deletedAt and isVisible columns on mobile (#26414)
* feat: SyncAssetV2

* feat: mobile sync handling

* feat: request correct sync object based on server version

* fix: mobile queries

* chore: sync sql

* fix: test

* chore: switch to mapper

* fix: sql sync
2026-02-23 09:50:54 -05:00
Daniel Dietzler
a07d7b0c82 chore: migrate to sql-tools library (#26400)
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-23 09:50:16 -05:00
Yaros
a469d350be feat(mobile): prompt when deleting from trash (#26392)
* feat(mobile): prompt when deleting from trash

* refactor: use existing strings

* chore: use type-safe translations

* chore: remove old translation function
2026-02-23 14:45:05 +00:00
Yaros
ccab4c88bb perf(mobile): optimized album sorting (#25179)
* perf(mobile): optimized album sorting

* refactor: add index & sql query

* fix: migration

* refactor: enum, ordering & list

* test: update album service tests

* chore: fix enums

broken during merging main

* chore: remove unnecessary tests

* test: add tests for getSortedAlbumIds

* test: added back stubs in service test
2026-02-23 20:13:45 +05:30
Min Idzelis
430638e129 feat: warn when losing transparency during thumbnail generation (#26243)
* feat: preserve alpha

* refactor: use isTransparent naming and separate getImageMetadata

* warn instead of preserve
2026-02-23 08:16:28 -05:00
Thomas
caebe5166a chore(mobile): remove redundant assignment (#26404)
The view controller is already assigned during page build. Reassigning
it for every drag doesn't really make any sense.
2026-02-23 12:48:25 +00:00
Michel Heusschen
1bd28c3e78 fix(web): prevent state_unsafe_mutation error on people page (#26438) 2026-02-23 13:24:51 +01:00
Matthew Momjian
31a55aaa73 fix(web): storage template example (#26424) 2026-02-23 10:34:56 +00:00
Thomas
8b2e1509ff chore(mobile): simplify pop logic (#26410)
We have all the information we need to decide on whether we should pop
or not at the end of a drag. There's no need to track that separately,
and update the value constantly.
2026-02-23 14:49:15 +05:30
Lauritz Tieste
d0cb97f994 feat(mobile): Add slug support for shared links (#26441)
* feat(mobile): add slug support for shared links

* fix(mobile): ensure slug retains existing value when unchanged
2026-02-23 14:31:42 +05:30
Timon
f0cf3311d5 feat(mobile): Allow users to set profile picture from asset viewer (#25517)
* init

* fix

* styling

* temporary workaround for 500 error

**Root cause:**
The autogenerated Dart OpenAPI client (`UsersApi.createProfileImage()`) had two issues:
1. It set `Content-Type: multipart/form-data` without a boundary, which overrode the correct header that Dart's `MultipartRequest` would set (`multipart/form-data; boundary=...`).
2. It added the file to both `mp.fields` and `mp.files`, creating a duplicate text field.

**Result:**
Multer on the server failed to parse the multipart body, so `@UploadedFile()` was `undefined` → accessing `file.path` in `UserService.createProfileImage()` threw → **500 Internal Server Error**.

**Workaround:**
Bypass the autogenerated method in `UserApiRepository.createProfileImage()` and send the multipart request directly using the same `ApiClient` (basePath + auth), ensuring:
- No manual `Content-Type` header (let `MultipartRequest` set it with boundary)
- File only in `mp.files`, not `mp.fields`
- Proper filename fallback

* Revert "temporary workaround for 500 error"

This reverts commit 8436cd402632ca7be9272a1c72fdaf0763dcefb6.

* generate route for ProfilePictureCropPage

* add route import

* simplify

* try this

* Revert "try this"

This reverts commit fcf37d2801055c49010ddb4fd271feb900ee645a.

* try patching

* Reapply "temporary workaround for 500 error"

This reverts commit faeed810c21e4c9f0839dfff1f34aa6183469e56.

* Revert "Reapply "temporary workaround for 500 error""

This reverts commit a14a0b76d14975af98ef91748576a79cef959635.

* fix upload

* Refactor image conversion logic by introducing a new utility function. Replace inline image-to-Uint8List conversion with the new utility in EditImagePage, DriftEditImagePage, and ProfilePictureCropPage.

* use toast over snack

* format

* Revert "try patching"

This reverts commit 68a616522a1eee88c4a9755a314c0017e6450c0f.

* Enhance toast notification in ProfilePictureCropPage to include success type for better user feedback.

* Revert "simplify"

This reverts commit 8e85057a40.

* format

* add tests

* refactor to use statefulwidget

* format

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-22 06:02:33 +00:00
Timon
3ce0654cab feat(mobile): Allow users to set album cover from mobile app (#25515)
* set album cover from asset

* add to correct kebab group

* add to album selection

* add to legacy control bottom bar

* add tests

* format

* analyze

* Revert "add to legacy control bottom bar"

This reverts commit 9d68e12a08.

* remove unnecessary event emission

* lint

* fix tests

* fix: button order and remove unncessary check

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-22 05:53:39 +00:00
Noel S
f0e2fced57 feat(mobile): video zooming in asset viewer (#22036)
* wip

* Functional implementation, still need to bug test.

* Fixed flickering bugs

* Fixed bug with drag actions interfering with zoom panning. Fixed video being zoomable when bottom sheet is shown. Code cleanup.

* Add comments and simplify video controls

* Clearer variable name

* Fix bug where the redundant onTapDown would interfere with zooming gestures

* Fix zoom not working the second time when viewing a video.

* fix video of live photo retaining pan from photo portion

* code cleanup and simplified widget stack

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-21 23:37:36 -06:00
Alex Balgavy
8ba20cbd44 feat: tap to see next/previous image (#20286)
* feat(mobile): tap behavior for next/previous image

This change enables switching to the next/previous photo in the photo
viewer by tapping the left/right quarter of the screen.

* Avoid animation on first/last image

* Add changes to asset_viewer.page

* Add setting for tap navigation, disable by default

Not everyone wants to have tapping for next/previous image enabled, so
this commit adds a settings toggle. Since it might be confusing behavior
for new users, it is disabled by default.

* chore: refactor

* fix: lint

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-02-22 05:28:17 +00:00
Mert
1d25267f22 fix(mobile): buffer width/height referenced after recycling (#26415)
recycle after getters
2026-02-21 09:41:44 -06:00
Michel Heusschen
a4d95b7aba fix(web): prevent side panel overlap during transition (#26398) 2026-02-21 09:14:53 -06:00
Min Idzelis
25d0bdc9f5 chore: replace remaining usages of npm with pnpm (#26411) 2026-02-21 08:44:33 -05:00
Michel Heusschen
905b9bd560 fix(web): album description auto height (#26420) 2026-02-21 08:43:23 -05:00
Michel Heusschen
672743f543 fix(web): escape handling on album page (#26419) 2026-02-21 08:42:31 -05:00
Michel Heusschen
27c45b5ddb fix(web): restore close action for asset viewer (#26418) 2026-02-21 10:31:30 +00:00
Peter Ombodi
82c6302549 feat(mobile): timeline - add persistentBottomBar flag (#25634)
* feat(mobile): timeline - add selectable all-assets control

* feature(mobile): introduce bottomWidgetBuilder in Timeline
remove redundant code

* fix(mobile): remove redundant code

* refactor(mobile): refactor new code in Timeline

* fix(mobile): fix format

* refactor(mobile): replace unsupported Dart syntax for analyzer compatibility

* refactor(mobile): remove Timeline.bottomSheet and migrate to bottomWidgetBuilder

* refactor(mobile): restore Timeline.bottomSheet and remove bottomWidgetBuilder
add withPersistentBottomBar param to Timeline class

* refactor(mobile): refactor var name

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-02-20 23:51:26 +05:30
Min Idzelis
aae64b5e2f test: thumbnail selector (#26383)
* test: face ordering issue/flakiness

* test: thumbnail selector
2026-02-20 15:04:17 +00:00
Benjamin Nguyen
18bf96b4b2 fix(mobile): handle userPreferencesProvider error state during sync (#26332)
fix drift_search_page render bug
2026-02-20 08:57:28 -06:00
Timon
84f2956941 fix(cli): delete sidecar files after upload if requested (#26353)
* fix(cli): delete sidecar files after upload if requested

Introduced a new function, findSidecar, to locate XMP sidecar files based on specified naming conventions. Updated the deleteFiles function to delete associated sidecar files when the main asset file is deleted. Added unit tests for findSidecar to ensure correct functionality.

* lint and format

* fix test

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-20 14:54:08 +00:00
Min Idzelis
6044b41648 fix: align devcontainers with standard development containers (#26321) 2026-02-20 09:37:07 -05:00
Min Idzelis
b4e16efdf4 test: face ordering issue/flakiness (#26382) 2026-02-20 09:23:40 -05:00
Min Idzelis
19da655390 fix: exiftool-vendored.exe (#26393) 2026-02-20 09:16:42 -05:00
Benjamin Nguyen
a1839b3676 fix(mobile): Reset "People" search filter chip if no selections are made (#26267)
* filter by tags

* reset people search filter chip if no selections
2026-02-20 16:37:26 +05:30
dotlambda
7461479f60 chore(ml): remove unused dependency ftfy (#25529) 2026-02-19 22:58:25 +00:00
Jason Rasmussen
01050a3d54 fix: pin code reset modal (#26370) 2026-02-19 21:50:39 +00:00
renovate[bot]
e8bedfdb7a chore(deps): update dependency @sveltejs/kit to v2.52.2 [security] (#26371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 16:19:19 -05:00
Timon
7b4cabc2c6 chore: update task commands in web/mise.toml to use pnpm (#26345)
* chore: update task commands in mise.toml to use pnpm

* Replaced direct commands with pnpm run equivalents for consistency.
* Added new tasks for type checking and Svelte checks.
* Removed deprecated svelte-kit-sync task and adjusted dependencies accordingly.

* mroe

* chore: update mise.toml to add demo server task

* Removed the direct IMMICH_SERVER_URL setting from the environment section.
* Added a new task for starting the demo server with the IMMICH_SERVER_URL environment variable.
* Ensured consistency in task definitions.
2026-02-19 16:10:55 -05:00
David Baxter
5c7c07a09f perf: add indexes to improve People API response times (#26337)
Add SQL indexes for people search endpoints
2026-02-19 16:09:05 -05:00
Jason Rasmussen
e6ac48f4b5 refactor: app download modal (#26368) 2026-02-19 16:03:46 -05:00
Jason Rasmussen
3d4dec0cca refactor: asset actions (#26367) 2026-02-19 20:42:37 +00:00
Jason Rasmussen
1d11106dd0 refactor: add to album (#26366) 2026-02-19 15:27:30 -05:00
Timon
8eec3c810e fix(web): single select scroll behavior (#26358)
refactor(timeline): remove single select scroll behavior
2026-02-19 15:21:03 -05:00
Thomas
a43680c8b1 chore(mobile): simplify drag logic (#26291)
We were manually tracking whether gestures should be blocked, which was
a remnant of how the old code worked. This is no longer needed as we
have better heuristics for knowing whether we should skip drag updates
now.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-19 14:18:44 -06:00
Jason Rasmussen
b2a510efee refactor: remove unused actions (#26363) 2026-02-19 12:52:21 -06:00
shenlong
a0077a0f51 feat(mobile): html text (#25739)
* feat: html text

* feat: mobile ui showcase (#25827)

* feat: mobile ui showcase

* remove showcase from main app

* update fonts

* update code to be loaded from asset

* fix ci

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
# Conflicts:
#	mobile/lib/widgets/common/immich_sliver_app_bar.dart

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-19 12:27:16 -06:00
Thomas
aa02310d63 chore(mobile): cleanup asset viewer state (#26300)
initState was quite noisy, so I've moved some things around and made the
intention a bit clearer.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-19 12:26:21 -06:00
renovate[bot]
7394fa1491 chore(deps): update dependency svelte to v5.51.5 [security] (#26352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 17:11:56 +00:00
Mees Frensel
99f7eb4ce6 chore(server): remove redundant nullish checks (#26354) 2026-02-19 17:09:12 +00:00
Timon
ffd54d0431 fix(i18n): add translation key for partner's photos (#26348)
* fix(i18n): add translation key for partner's photos

* reuse existing key
2026-02-19 10:53:19 -06:00
Michel Heusschen
7005e9fc50 fix(web): update @immich/ui to v0.64.0 (#26351) 2026-02-19 16:33:06 +00:00
Michel Heusschen
4f2e6e3f15 fix(web): favoriting assets opened via GalleryViewer (#26350)
fix(web): favoriting assets through GalleryViewer
2026-02-19 10:32:25 -06:00
Michel Heusschen
8b5fc3d8bc fix(web): prevent panorama image reload during asset updates (#26349) 2026-02-19 10:31:30 -06:00
Thomas
0fa385c465 fix(mobile): infer drag intent early (#26344)
The drag intent was not set until it reached the kTouchSlop threshold.
This is not necessary as flutter already has its own heuristics for
preventing unintended drags.

The result of using kTouchSlop is that dismissing or scroll can feel a
little delayed, and will jump from 0 to kTouchSlop (18px) rather than
moving smoothly.
2026-02-19 09:56:51 -06:00
Daniel Dietzler
db4e7abf6d chore: refactor more queries (#25572)
* refactor: asset service queries

* chore: refactor more queries
2026-02-19 10:48:30 -05:00
Thomas
dadd20acfc chore(mobile): reduce the asset details snap target (#26343)
We were snapping to 75%, but 66.6% may be more natural.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-19 15:48:21 +00:00
Jason Rasmussen
f04efbb714 fix: safari address bar color (#26346) 2026-02-19 09:40:13 -06:00
Timon
208c07af1f chore(web): merge "Add to album" and "Add to shared album" actions into a single action (#24669)
* refactor: simplify album selection actions by removing shared option

* Removed the shared option from AddToAlbumAction and related components.
* Updated AlbumPickerModal and other components to reflect this change.
* Cleaned up related tests and documentation for consistency.

* fix lint
2026-02-19 16:15:26 +01:00
Jason Rasmussen
72a5ccaa53 feat: editing descriminator (#26336) 2026-02-19 09:15:56 -05:00
Daniel Dietzler
fd0338f89c refactor: asset service queries (#25535) 2026-02-19 08:54:28 -05:00
Daniel Dietzler
d0ed76dc37 refactor: small face tests (#26340) 2026-02-19 08:51:18 -05:00
renovate[bot]
e0bb5f70ec fix(deps): update dependency fabric to v7 [security] (#26342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 12:28:12 +00:00
Timon
f965daa8d2 chore: remove push trigger for check-openapi workflow (#26341) 2026-02-19 13:14:26 +01:00
Timon
316f86d25e feat: add .mxf file support (#24644)
* feat: add support for MXF format in media handling

* Updated supported formats documentation to include MXF.
* Added MXF to valid video extensions in tests.
* Registered MXF MIME type in mime-types utility.

* fix: enhance MXF handling in mime-types utility

* Updated video mime type validation to include 'application/mxf'.
* Adjusted asset type determination to recognize MXF as a video container.

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-19 06:39:41 +00:00
Hao Xi
e520fc3b63 fix: include DROP INDEX in transaction to prevent missing index on rollback (#25399)
* fix: ERR_PNPM_ENOENT error while `make dev` on macOS.

* fix: include `DROP INDEX` in transaction to prevent missing index on rollback.

* chore: clean up this PR.
2026-02-19 06:20:36 +00:00
Jonathan Jogenfors
b3b9834c00 feat(web): loop chromecast video (#24410) 2026-02-18 20:29:13 -05:00
Mees Frensel
84f7fb63ee feat(web): show ocr text boxes in panoramas (#25727) 2026-02-18 20:04:18 -05:00
Jorge Montejo
1f8359ead4 fix: Download the edited version when downloading multiple photos (#26259)
* fix: download the edited version when downloading multiple photos

* test: update tests

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-18 21:47:45 +00:00
Fabio Garavini
ea30c9d2ba fix(server): db restore failure when DB_URL is set to unix-domain socket connection (#26252)
* fix db restore fails to get postgres user

* Apply suggestion from @danieldietzler

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

* fix fallback to reasonable default test

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-02-18 16:37:40 -05:00
Dusan Hlavaty
d1abdea420 chore(docs): add quick-start guide for DevPod with docker (#26213) 2026-02-18 16:26:09 -05:00
Benjamin Nguyen
ae8dad68fc feat(mobile): filter by tags (#26196)
filter by tags
2026-02-18 21:16:26 +00:00
renovate[bot]
227ff70b6e chore(deps): update dependency ajv to v8.18.0 [security] (#26297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 16:06:43 -05:00
bo0tzz
ee7ac09450 fix: bad field paste in metadata.service.spec (#26329)
Caused https://github.com/immich-app/immich/actions/runs/22153269773/job/64050176244 to flake
2026-02-18 16:04:32 -05:00
Devansh H Jani
2e59dbdc12 fix: prevent server crash when extraction of metadata fails if the assets are corrupted (#26042)
* Fix-25968 Extraction of metadata fails if the assets are corrupted

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-18 15:53:23 -05:00
Timon
c4c7f94317 chore: add OpenAPI check workflow (#26223) 2026-02-18 15:16:01 -05:00
Jason Rasmussen
d004d7e21b fix: metadata crash (#26327) 2026-02-18 15:09:35 -05:00
Timon
5f95aab437 chore: align mobile mise tasks (#26237)
* chore: align mobile mise tasks

* fix
2026-02-18 14:11:01 -05:00
Michel Heusschen
dd632f38de fix(web): unblock escape after opening context menu (#26325) 2026-02-18 14:10:15 -05:00
renovate[bot]
6f7fc94710 chore(deps): update github-actions (#25388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 19:53:41 +01:00
renovate[bot]
85cb515cae chore(deps): update dependency github:cqlabs/homebrew-dcm to v1.35.1 (#26278)
* chore(deps): update dependency github:cqlabs/homebrew-dcm to v1.35.1

* fix static analysis

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-18 23:47:54 +05:30
bo0tzz
65e1bb83b7 fix: use wrangler cli directly (#26326) 2026-02-18 18:06:41 +00:00
bo0tzz
d9b1b69827 fix: switch docs-deploy to use wrangler-action (#26323) 2026-02-18 18:45:43 +01:00
Min Idzelis
b2050583f5 chore: run maintenance test (e2e) in isolation too, share containers (#26246) 2026-02-18 09:39:13 -05:00
Keunes
1bdc24c730 feat(docs): Explain configuration file location for Docker Compose (#24989)
* Explain configuration file location for Docker Compose

* Update config-file.md

* Update config-file.md

* Update config-file.md

---------

Co-authored-by: Mees Frensel <33722705+meesfrensel@users.noreply.github.com>
2026-02-18 14:52:28 +01:00
renovate[bot]
5adb75c272 fix(deps): update dependency @mapbox/mapbox-gl-rtl-text to v0.3.0 (#23353)
* fix(deps): update dependency @mapbox/mapbox-gl-rtl-text to v0.3.0

* fix: maplibre rtl import

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-02-18 12:05:41 +01:00
577 changed files with 20665 additions and 14807 deletions

View File

@@ -2,6 +2,7 @@
"name": "Immich - Backend, Frontend and ML",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -31,29 +32,8 @@
"tasks": {
"version": "2.0.0",
"tasks": [
{
"label": "Fix Permissions, Install Dependencies",
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{
"label": "Immich API Server (Nest)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
"isBackground": true,
@@ -74,7 +54,6 @@
},
{
"label": "Immich Web Server (Vite)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
"isBackground": true,
@@ -130,8 +109,8 @@
}
},
"overrideCommand": true,
"workspaceFolder": "/workspaces/immich",
"remoteUser": "node",
"workspaceFolder": "/usr/src/app",
"remoteUser": "root",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored

View File

@@ -1,23 +1,17 @@
services:
immich-app-base:
image: busybox
immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build:
target: dev-container-mobile
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override # bind mount host to /workspaces/immich
- ..:/workspaces/immich
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- /etc/localtime:/etc/localtime:ro
immich-web:
env_file: !reset []

View File

@@ -2,6 +2,7 @@
"name": "Immich - Mobile",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -35,7 +36,7 @@
},
"forwardPorts": [],
"overrideCommand": true,
"workspaceFolder": "/workspaces/immich",
"workspaceFolder": "/usr/src/app",
"remoteUser": "node",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {

View File

@@ -2,11 +2,6 @@
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
export DEV_PORT="${DEV_PORT:-3000}"
# search for immich directory inside workspace.
# /workspaces/immich is the bind mount, but other directories can be mounted if runing
# Devcontainer: Clone [repository|pull request] in container volumne
WORKSPACES_DIR="/workspaces"
IMMICH_DIR="$WORKSPACES_DIR/immich"
IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
log() {
@@ -30,52 +25,8 @@ run_cmd() {
return "${PIPESTATUS[0]}"
}
# Find directories excluding /workspaces/immich
mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*")
if [ ${#other_dirs[@]} -gt 1 ]; then
log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR."
exit 1
elif [ ${#other_dirs[@]} -eq 1 ]; then
export IMMICH_WORKSPACE="${other_dirs[0]}"
else
export IMMICH_WORKSPACE="$IMMICH_DIR"
fi
export IMMICH_WORKSPACE="/usr/src/app"
log "Found immich workspace in $IMMICH_WORKSPACE"
log ""
fix_permissions() {
log "Fixing permissions for ${IMMICH_WORKSPACE}"
# Change ownership for directories that exist
for dir in "${IMMICH_WORKSPACE}/.vscode" \
"${IMMICH_WORKSPACE}/server/upload" \
"${IMMICH_WORKSPACE}/.pnpm-store" \
"${IMMICH_WORKSPACE}/.github/node_modules" \
"${IMMICH_WORKSPACE}/cli/node_modules" \
"${IMMICH_WORKSPACE}/e2e/node_modules" \
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
"${IMMICH_WORKSPACE}/server/node_modules" \
"${IMMICH_WORKSPACE}/server/dist" \
"${IMMICH_WORKSPACE}/web/node_modules" \
"${IMMICH_WORKSPACE}/web/dist"; do
if [ -d "$dir" ]; then
run_cmd sudo chown node -R "$dir"
fi
done
log ""
}
install_dependencies() {
log "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1
run_cmd make setup-web-dev setup-server-dev
)
log ""
}

View File

@@ -1,26 +1,21 @@
services:
immich-app-base:
image: busybox
immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build:
target: dev-container-server
env_file: !reset []
hostname: immich-dev
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
- ..:/workspaces/immich
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []

View File

@@ -1,17 +0,0 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..."
fix_permissions
log "Setup complete, please wait while backend and frontend services automatically start"
log
log "If necessary, the services may be manually started using"
log
log "$ /immich-devcontainer/container-start-backend.sh"
log "$ /immich-devcontainer/container-start-frontend.sh"
log
log "From different terminal windows, as these scripts automatically restart the server"
log "on error, and will continuously run in a loop"

View File

@@ -51,14 +51,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -79,12 +79,12 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -96,14 +96,14 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'zulu'
java-version: '17'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/caches
@@ -160,7 +160,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -185,7 +185,7 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false

View File

@@ -19,13 +19,13 @@ jobs:
actions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check out code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

32
.github/workflows/check-openapi.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Check OpenAPI
on:
workflow_dispatch:
pull_request:
paths:
- 'open-api/**'
- '.github/workflows/check-openapi.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
check-openapi:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check for breaking API changes
# sha is pinning to a commit instead of a tag since the action does not tag versions
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR

View File

@@ -31,12 +31,12 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -45,7 +45,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
@@ -71,13 +71,13 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -89,7 +89,7 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -115,7 +115,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64

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:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -44,20 +44,20 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
# 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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
category: '/language:${{matrix.language}}'

View File

@@ -23,14 +23,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read
actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read
actions: read

View File

@@ -21,14 +21,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -54,13 +54,13 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -70,7 +70,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'

View File

@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,19 +119,19 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- name: Load parameters
id: parameters
@@ -192,16 +192,13 @@ jobs:
' >> $GITHUB_OUTPUT
- name: Publish to Cloudflare Pages
# TODO: Action is deprecated
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ steps.docs-output.outputs.projectName }}
workingDirectory: 'docs'
directory: 'build'
branch: ${{ steps.parameters.outputs.name }}
wranglerVersion: '3'
working-directory: docs
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PROJECT_NAME: ${{ steps.docs-output.outputs.projectName }}
BRANCH_NAME: ${{ steps.parameters.outputs.name }}
run: mise run //docs:deploy
- name: Deploy Docs Release Domain
if: ${{ steps.parameters.outputs.event == 'release' }}

View File

@@ -17,19 +17,19 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}
@@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -56,20 +56,20 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -130,7 +130,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -23,20 +23,20 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -159,7 +159,7 @@ jobs:
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false

View File

@@ -19,12 +19,12 @@ jobs:
working-directory: ./open-api/typescript-sdk
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -20,14 +20,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -49,13 +49,13 @@ jobs:
working-directory: ./mobile
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -69,6 +69,14 @@ jobs:
- name: Install dependencies
run: dart pub get
- name: Install dependencies for UI package
run: dart pub get
working-directory: ./mobile/packages/ui
- name: Install dependencies for UI Showcase
run: dart pub get
working-directory: ./mobile/packages/ui/showcase
- name: Install DCM
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
with:

View File

@@ -17,14 +17,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -63,13 +63,13 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -108,20 +108,20 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -155,20 +155,20 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -197,20 +197,20 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -241,20 +241,20 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -279,20 +279,20 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -327,20 +327,20 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -373,13 +373,13 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
@@ -387,7 +387,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -412,13 +412,13 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
@@ -426,7 +426,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -446,12 +446,29 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose build
- name: Start Docker Compose
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test
if: ${{ !cancelled() }}
- name: Run e2e tests (maintenance)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test:maintenance
if: ${{ !cancelled() }}
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web:
name: End-to-End Tests (Web)
needs: pre-job
@@ -467,13 +484,13 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
@@ -481,7 +498,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -494,16 +511,15 @@ jobs:
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install chromium --only-shell
run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
env:
CI: true
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: npx playwright test --project=web
run: pnpm test:web
if: ${{ !cancelled() }}
- name: Archive e2e test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
@@ -513,9 +529,8 @@ jobs:
path: e2e/playwright-report/
- name: Run ui tests (web)
env:
CI: true
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: npx playwright test --project=ui
run: pnpm test:web:ui
if: ${{ !cancelled() }}
- name: Archive ui test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
@@ -525,9 +540,8 @@ jobs:
path: e2e/playwright-report/
- name: Run maintenance tests
env:
CI: true
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: npx playwright test --project=maintenance
run: pnpm test:web:maintenance
if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
@@ -543,7 +557,7 @@ jobs:
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: docker-compose-logs-${{ matrix.runner }}
name: e2e-web-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
success-check-e2e:
name: End-to-End Tests Success
@@ -564,12 +578,12 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -596,17 +610,17 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
python-version: 3.11
- name: Install dependencies
@@ -636,20 +650,20 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -666,12 +680,12 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -687,20 +701,20 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -749,20 +763,20 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -24,14 +24,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -4,12 +4,18 @@ module.exports = {
if (!pkg.name) {
return pkg;
}
// make exiftool-vendored.pl a regular dependency since Docker prod
// images build with --no-optional to reduce image size
if (pkg.name === "exiftool-vendored") {
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
// make exiftool-vendored.pl a regular dependency
pkg.dependencies["exiftool-vendored.pl"] =
pkg.optionalDependencies["exiftool-vendored.pl"];
delete pkg.optionalDependencies["exiftool-vendored.pl"];
const binaryPackage =
process.platform === "win32"
? "exiftool-vendored.exe"
: "exiftool-vendored.pl";
if (pkg.optionalDependencies[binaryPackage]) {
pkg.dependencies[binaryPackage] =
pkg.optionalDependencies[binaryPackage];
delete pkg.optionalDependencies[binaryPackage];
}
}
return pkg;

View File

@@ -52,7 +52,7 @@ attach-server:
docker exec -it docker_immich-server_1 sh
renovate:
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset
# Directories that need to be created for volumes or build output
VOLUME_DIRS = \

View File

@@ -13,7 +13,7 @@
"cli"
],
"devDependencies": {
"@eslint/js": "^9.8.0",
"@eslint/js": "^10.0.0",
"@immich/sdk": "workspace:*",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
@@ -25,11 +25,11 @@
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"eslint": "^9.14.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"eslint-plugin-unicorn": "^63.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
@@ -45,8 +45,8 @@
"build": "vite build",
"build:dev": "vite build --sourcemap true",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"prepack": "npm run build",
"lint:fix": "pnpm run lint --fix",
"prepack": "pnpm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --check .",

View File

@@ -7,7 +7,15 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock';
import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
import {
checkForDuplicates,
deleteFiles,
findSidecar,
getAlbumName,
startWatch,
uploadFiles,
UploadOptionsDto,
} from 'src/commands/asset';
vi.mock('@immich/sdk');
@@ -309,3 +317,85 @@ describe('startWatch', () => {
await fs.promises.rm(testFolder, { recursive: true, force: true });
});
});
describe('findSidecar', () => {
let testDir: string;
let testFilePath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-'));
testFilePath = path.join(testDir, 'test.jpg');
fs.writeFileSync(testFilePath, 'test');
});
afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});
it('should find sidecar file with photo.xmp naming convention', () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
const result = findSidecar(testFilePath);
expect(result).toBe(sidecarPath);
});
it('should find sidecar file with photo.ext.xmp naming convention', () => {
const sidecarPath = path.join(testDir, 'test.jpg.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
const result = findSidecar(testFilePath);
expect(result).toBe(sidecarPath);
});
it('should prefer photo.ext.xmp over photo.xmp when both exist', () => {
const sidecarPath1 = path.join(testDir, 'test.xmp');
const sidecarPath2 = path.join(testDir, 'test.jpg.xmp');
fs.writeFileSync(sidecarPath1, 'xmp data 1');
fs.writeFileSync(sidecarPath2, 'xmp data 2');
const result = findSidecar(testFilePath);
// Should return the first one found (photo.xmp) based on the order in the code
expect(result).toBe(sidecarPath1);
});
it('should return undefined when no sidecar file exists', () => {
const result = findSidecar(testFilePath);
expect(result).toBeUndefined();
});
});
describe('deleteFiles', () => {
let testDir: string;
let testFilePath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-'));
testFilePath = path.join(testDir, 'test.jpg');
fs.writeFileSync(testFilePath, 'test');
});
afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});
it('should delete asset and sidecar file when main file is deleted', async () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 });
expect(fs.existsSync(testFilePath)).toBe(false);
expect(fs.existsSync(sidecarPath)).toBe(false);
});
it('should not delete sidecar file when delete option is false', async () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 });
expect(fs.existsSync(testFilePath)).toBe(true);
expect(fs.existsSync(sidecarPath)).toBe(true);
});
});

View File

@@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar';
import { MultiBar, Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es';
import micromatch from 'micromatch';
import { Stats, createReadStream } from 'node:fs';
import { Stats, createReadStream, existsSync } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import path, { basename } from 'node:path';
import { Queue } from 'src/queue';
@@ -403,23 +403,6 @@ export const uploadFiles = async (
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
const { baseUrl, headers } = defaults;
const assetPath = path.parse(input);
const noExtension = path.join(assetPath.dir, assetPath.name);
const sidecarsFiles = await Promise.all(
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
try {
const stats = await stat(sidecarPath);
return new UploadFile(sidecarPath, stats.size);
} catch {
return false;
}
}),
);
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
const formData = new FormData();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI');
@@ -429,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
if (sidecarData) {
formData.append('sidecarData', sidecarData);
const sidecarPath = findSidecar(input);
if (sidecarPath) {
try {
const stats = await stat(sidecarPath);
const sidecarData = new UploadFile(sidecarPath, stats.size);
formData.append('sidecarData', sidecarData);
} catch {
// noop
}
}
const response = await fetch(`${baseUrl}/assets`, {
@@ -446,7 +436,19 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
return response.json();
};
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
export const findSidecar = (filepath: string): string | undefined => {
const assetPath = path.parse(filepath);
const noExtension = path.join(assetPath.dir, assetPath.name);
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) {
if (existsSync(sidecarPath)) {
return sidecarPath;
}
}
};
export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
let fileCount = 0;
if (options.delete) {
fileCount += uploaded.length;
@@ -474,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo
const chunkDelete = async (files: Asset[]) => {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
await Promise.all(
assetBatch.map(async (input: Asset) => {
await unlink(input.filepath);
const sidecarPath = findSidecar(input.filepath);
if (sidecarPath) {
await unlink(sidecarPath);
}
}),
);
deletionProgress.update(assetBatch.length);
}
};

View File

@@ -44,7 +44,7 @@ While this guide focuses on VS Code, you have many options for Dev Container dev
**Self-Hostable Options:**
- [Coder](https://coder.com) - Enterprise-focused, requires Terraform knowledge, self-managed
- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise)
- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise). Check [quick-start guide](#quick-start-guide-for-devpod-with-docker)
:::
## Dev Container Services
@@ -410,6 +410,26 @@ If you encounter issues:
3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/)
4. Ask in [Discord](https://discord.immich.app) `#contributing` channel
### Quick-start guide for DevPod with docker
You will need DevPod CLI (check [DevPod CLI installation guide](https://devpod.sh/docs/getting-started/install)) and Docker Desktop.
```sh
# Step 1: Clone the Repository
git clone https://github.com/immich-app/immich.git
cd immich
# Step 2: Prepare DevPod (if you haven't already)
devpod provider add docker
devpod provider use docker
# Step 3: Build 'immich-server-dev' docker image first manually
docker build -f server/Dockerfile.dev -t immich-server-dev .
# Step 4: Now you can start devcontainer
devpod up .
```
## Mobile Development
While the Dev Container focuses on server and web development, you can connect mobile apps for testing:

View File

@@ -80,6 +80,10 @@ There is an automatic scan job that is scheduled to run once a day. Its schedule
This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
### Deleting a Library
When deleting an external library, all assets inside are immediately deleted along with the library. Note that while a library can take a long time to fully delete in the background, it is immediately removed from the library list. If the deletion process is interrupted (for example, due to server restart), it will be cleaned up in the next nightly cron job. The cleanup process can also be manually initiated by clicking the "Scan All Libraries" button in the library list.
## Usage
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:

View File

@@ -38,6 +38,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |

View File

@@ -8,7 +8,8 @@ A config file can be provided as an alternative to the UI configuration.
### Step 1 - Create a new config file
In JSON format, create a new config file (e.g. `immich.json`) and put it in a location that can be accessed by Immich.
In JSON format, create a new config file (e.g. `immich.json`) and put it in a location mounted in the container that can be accessed by Immich.
YAML-formatted config files are also supported.
The default configuration looks like this:
<details>
@@ -251,6 +252,15 @@ So you can just grab it from there, paste it into a file and you're pretty much
In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
For more information, refer to the [Environment Variables](/install/environment-variables.md) section.
:::tip
YAML-formatted config files are also supported.
:::
:::info Docker Compose
In your `.env` file, the variables `UPLOAD_LOCATION` and `DB_DATA_LOCATION` concern the location on the host.
However, the variable `IMMICH_CONFIG_FILE` concerns the location inside the container, and informs the `immich-server` container that a configuration file is present.
It is recommended to reuse this variable in your `docker-compose.yml`:
```yaml
volumes:
- ./configuration.yml:${IMMICH_CONFIG_FILE}
```
::

View File

@@ -23,3 +23,9 @@ run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."
[tasks.deploy]
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.66.0"

View File

@@ -8,7 +8,7 @@
"format:fix": "prettier --write .",
"start": "docusaurus start --port 3005",
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"build": "npm run copy:openapi && docusaurus build",
"build": "pnpm run copy:openapi && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",

View File

@@ -7,19 +7,24 @@
"scripts": {
"test": "vitest --run",
"test:watch": "vitest",
"test:web": "npx playwright test",
"start:web": "npx playwright test --ui",
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
"test:web": "pnpm exec playwright test --project=web",
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
"test:web:ui": "pnpm exec playwright test --project=ui",
"start:web": "pnpm exec playwright test --ui --project=web",
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/js": "^9.8.0",
"@eslint/js": "^10.0.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
@@ -32,12 +37,12 @@
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"dotenv": "^17.2.3",
"eslint": "^9.14.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^34.3.0",
"globals": "^16.0.0",
"eslint-plugin-unicorn": "^63.0.0",
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
"pngjs": "^7.0.0",

View File

@@ -48,7 +48,7 @@ const config: PlaywrightTestConfig = {
{
name: 'maintenance',
use: { ...devices['Desktop Chrome'] },
testDir: './src/specs/maintenance',
testDir: './src/specs/maintenance/web',
workers: 1,
},
],

View File

@@ -253,7 +253,8 @@ describe('/asset', () => {
expect(status).toBe(200);
expect(body.id).toEqual(facesAsset.id);
expect(body.people).toMatchObject(expectedFaces);
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
expect(sortedPeople).toMatchObject(expectedFaces);
});
});

View File

@@ -45,8 +45,7 @@ test.describe('Shared Links', () => {
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
await page.waitForSelector('[data-group] svg');
await page.getByRole('checkbox').click();
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
});

View File

@@ -438,7 +438,7 @@ test.describe('Timeline', () => {
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
});
test('Add photos to album', async ({ page }) => {
const album = timelineRestData.album;
@@ -447,7 +447,7 @@ test.describe('Timeline', () => {
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
const requestJson = request.postDataJSON();

View File

@@ -65,7 +65,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
return page.locator('[data-thumbnail-focus-container][data-selected]');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
@@ -102,12 +102,9 @@ export const thumbnailUtils = {
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
async expectSelectedDisabled(page: Page, assetId: string) {
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {

View File

@@ -1,15 +1,20 @@
import { defineConfig } from 'vitest/config';
// skip `docker compose up` if `make e2e` was already run
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set
const globalSetup: string[] = [];
try {
await fetch('http://127.0.0.1:2285/api/server/ping');
} catch {
globalSetup.push('src/docker-compose.ts');
if (!skipDockerSetup) {
try {
await fetch('http://127.0.0.1:2285/api/server/ping');
} catch {
globalSetup.push('src/docker-compose.ts');
}
}
export default defineConfig({
test: {
retry: process.env.CI ? 4 : 0,
include: ['src/specs/server/**/*.e2e-spec.ts'],
globalSetup,
testTimeout: 15_000,

View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set
const globalSetup: string[] = [];
if (!skipDockerSetup) {
try {
await fetch('http://127.0.0.1:2285/api/server/ping');
} catch {
globalSetup.push('src/docker-compose.ts');
}
}
export default defineConfig({
test: {
retry: process.env.CI ? 4 : 0,
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
globalSetup,
testTimeout: 15_000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
},
});

View File

@@ -1074,6 +1074,7 @@
"failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.",
"page_not_found": "Page not found :/",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -1218,6 +1219,7 @@
"filter_description": "Conditions to filter the target assets",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filter_tags": "Filter tags",
"filters": "Filters",
"find_them_fast": "Find them fast by name with search",
"first": "First",
@@ -1945,6 +1947,7 @@
"search_filter_ocr": "Search by OCR",
"search_filter_people_title": "Select people",
"search_filter_star_rating": "Star Rating",
"search_filter_tags_title": "Select tags",
"search_for": "Search for",
"search_for_existing_person": "Search for existing person",
"search_no_more_result": "No more results",
@@ -2024,6 +2027,9 @@
"set_profile_picture": "Set profile picture",
"set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
"set_stack_primary_asset": "Set as primary asset",
"setting_image_navigation_enable_subtitle": "If enabled, you can navigate to the previous/next image by tapping the leftmost/rightmost quarter of the screen.",
"setting_image_navigation_enable_title": "Tap to Navigate",
"setting_image_navigation_title": "Image Navigation",
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
"setting_image_viewer_original_title": "Load original image",
@@ -2302,6 +2308,7 @@
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",

View File

@@ -8,7 +8,6 @@ readme = "README.md"
dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"ftfy>=6.1.1",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",

View File

@@ -654,18 +654,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/25/fab23259a52ece5670dcb8452e1af34b89e6135ecc17cd4b54b4b479eac6/fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960", size = 168979, upload-time = "2023-12-11T21:19:52.446Z" },
]
[[package]]
name = "ftfy"
version = "6.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/d3/8650919bc3c7c6e90ee3fa7fd618bf373cbbe55dff043bd67353dbb20cd8/ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec", size = 308927, upload-time = "2024-10-26T00:50:35.149Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" },
]
[[package]]
name = "gevent"
version = "24.10.3"
@@ -788,14 +776,14 @@ wheels = [
[[package]]
name = "gunicorn"
version = "23.0.0"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
@@ -939,7 +927,6 @@ source = { editable = "." }
dependencies = [
{ name = "aiocache" },
{ name = "fastapi" },
{ name = "ftfy" },
{ name = "gunicorn" },
{ name = "huggingface-hub" },
{ name = "insightface" },
@@ -1018,7 +1005,6 @@ types = [
requires-dist = [
{ name = "aiocache", specifier = ">=0.12.1,<1.0" },
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
{ name = "ftfy", specifier = ">=6.1.1" },
{ name = "gunicorn", specifier = ">=21.1.0" },
{ name = "huggingface-hub", specifier = ">=0.20.1,<1.0" },
{ name = "insightface", specifier = ">=0.7.3,<1.0" },

View File

@@ -16,13 +16,13 @@ config_roots = [
[tools]
node = "24.13.1"
flutter = "3.35.7"
pnpm = "10.29.3"
pnpm = "10.30.0"
terragrunt = "0.98.0"
opentofu = "1.11.4"
java = "21.0.2"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"
version = "1.35.1"
bin = "dcm"
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
@@ -37,13 +37,12 @@ run = "pnpm install --filter @immich/sdk --frozen-lockfile"
[tasks."sdk:build"]
dir = "open-api/typescript-sdk"
env._.path = "./node_modules/.bin"
run = "tsc"
run = "pnpm run build"
# i18n tasks
[tasks."i18n:format"]
dir = "i18n"
run = { task = ":i18n:format-fix" }
run = "pnpm run format"
[tasks."i18n:format-fix"]
dir = "i18n"

View File

@@ -48,7 +48,6 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
try {
val buffer = NativeBuffer.wrap(pointer, size)
copyPixelsToBuffer(buffer)
recycle()
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
@@ -57,8 +56,9 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
)
} catch (e: Exception) {
NativeBuffer.free(pointer)
recycle()
throw e
} finally {
recycle()
}
}

View File

@@ -1 +1 @@
version: '>=1.29.0 <=1.30.0'
version: '>=1.29.0 <=1.36.0'

File diff suppressed because one or more lines are too long

View File

@@ -18,3 +18,5 @@ enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly }
enum AssetDateAggregation { start, end }

View File

@@ -73,6 +73,9 @@ enum StoreKey<T> {
autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
// Image viewer navigation settings
tapToNavigate<bool>._(141),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),

View File

@@ -0,0 +1,29 @@
import 'package:openapi/api.dart';
class Tag {
final String id;
final String value;
const Tag({required this.id, required this.value});
@override
String toString() {
return 'Tag(id: $id, value: $value)';
}
@override
bool operator ==(covariant Tag other) {
if (identical(this, other)) return true;
return other.id == id && other.value == value;
}
@override
int get hashCode {
return id.hashCode ^ value.hashCode;
}
static Tag fromDto(TagResponseDto dto) {
return Tag(id: dto.id, value: dto.value);
}
}

View File

@@ -43,8 +43,8 @@ class RemoteAlbumService {
AlbumSortMode.title => albums.sortedBy((album) => album.name),
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end),
AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start),
};
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
@@ -172,46 +172,25 @@ class RemoteAlbumService {
return _repository.getAlbumsContainingAsset(assetId);
}
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
Future<List<RemoteAlbum>> _sortByAssetDate(
List<RemoteAlbum> albums, {
required AssetDateAggregation aggregation,
}) async {
if (albums.isEmpty) return [];
final albumIds = albums.map((e) => e.id).toList();
final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation);
final albumMap = Map<String, RemoteAlbum>.fromEntries(albums.map((a) => MapEntry(a.id, a)));
final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType<RemoteAlbum>().toList();
if (sortedAlbums.length < albums.length) {
final returnedIdSet = sortedIds.toSet();
final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id));
sortedAlbums.addAll(emptyAlbums);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
}
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
return sortedAlbums;
}
}

View File

@@ -68,12 +68,12 @@ class SyncStreamService {
return false;
}
final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
final migrations = (jsonDecode(value) as List).cast<String>();
int previousLength = migrations.length;
await _runPreSyncTasks(migrations, semVer);
await _runPreSyncTasks(migrations, serverSemVer);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
@@ -82,10 +82,14 @@ class SyncStreamService {
// Start the sync stream and handle events
bool shouldReset = false;
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
await _syncApiRepository.streamChanges(
_handleEvents,
serverVersion: serverSemVer,
onReset: () => shouldReset = true,
);
if (shouldReset) {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents);
await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
}
previousLength = migrations.length;
@@ -282,6 +286,8 @@ class SyncStreamService {
return _syncStreamRepository.deletePeopleV1(data.cast());
case SyncEntityType.assetFaceV1:
return _syncStreamRepository.updateAssetFacesV1(data.cast());
case SyncEntityType.assetFaceV2:
return _syncStreamRepository.updateAssetFacesV2(data.cast());
case SyncEntityType.assetFaceDeleteV1:
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
default:

View File

@@ -28,6 +28,10 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin {
TextColumn get sourceType => text()();
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
DateTimeColumn get deletedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}

View File

@@ -5,11 +5,12 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
as i1;
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'
as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i3;
import 'package:drift/internal/modular.dart' as i4;
as i4;
import 'package:drift/internal/modular.dart' as i5;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i5;
as i6;
typedef $$AssetFaceEntityTableCreateCompanionBuilder =
i1.AssetFaceEntityCompanion Function({
@@ -23,6 +24,8 @@ typedef $$AssetFaceEntityTableCreateCompanionBuilder =
required int boundingBoxX2,
required int boundingBoxY2,
required String sourceType,
i0.Value<bool> isVisible,
i0.Value<DateTime?> deletedAt,
});
typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
i1.AssetFaceEntityCompanion Function({
@@ -36,6 +39,8 @@ typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
i0.Value<int> boundingBoxX2,
i0.Value<int> boundingBoxY2,
i0.Value<String> sourceType,
i0.Value<bool> isVisible,
i0.Value<DateTime?> deletedAt,
});
final class $$AssetFaceEntityTableReferences
@@ -51,29 +56,29 @@ final class $$AssetFaceEntityTableReferences
super.$_typedResult,
);
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.assetId,
i4.ReadDatabaseContainer(
i5.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i3
final manager = i4
.$$RemoteAssetEntityTableTableManager(
$_db,
i4.ReadDatabaseContainer(
i5.ReadDatabaseContainer(
$_db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
@@ -83,29 +88,29 @@ final class $$AssetFaceEntityTableReferences
);
}
static i5.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i5.$PersonEntityTable>('person_entity')
static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i6.$PersonEntityTable>('person_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.personId,
i4.ReadDatabaseContainer(
i5.ReadDatabaseContainer(
db,
).resultSet<i5.$PersonEntityTable>('person_entity').id,
).resultSet<i6.$PersonEntityTable>('person_entity').id,
),
);
i5.$$PersonEntityTableProcessedTableManager? get personId {
i6.$$PersonEntityTableProcessedTableManager? get personId {
final $_column = $_itemColumn<String>('person_id');
if ($_column == null) return null;
final manager = i5
final manager = i6
.$$PersonEntityTableTableManager(
$_db,
i4.ReadDatabaseContainer(
i5.ReadDatabaseContainer(
$_db,
).resultSet<i5.$PersonEntityTable>('person_entity'),
).resultSet<i6.$PersonEntityTable>('person_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_personIdTable($_db));
@@ -165,24 +170,34 @@ class $$AssetFaceEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column),
);
i3.$$RemoteAssetEntityTableFilterComposer get assetId {
final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
i0.ColumnFilters<bool> get isVisible => $composableBuilder(
column: $table.isVisible,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get deletedAt => $composableBuilder(
column: $table.deletedAt,
builder: (column) => i0.ColumnFilters(column),
);
i4.$$RemoteAssetEntityTableFilterComposer get assetId {
final i4.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
referencedTable: i5.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableFilterComposer(
}) => i4.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$table: i5.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@@ -192,24 +207,24 @@ class $$AssetFaceEntityTableFilterComposer
return composer;
}
i5.$$PersonEntityTableFilterComposer get personId {
final i5.$$PersonEntityTableFilterComposer composer = $composerBuilder(
i6.$$PersonEntityTableFilterComposer get personId {
final i6.$$PersonEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.personId,
referencedTable: i4.ReadDatabaseContainer(
referencedTable: i5.ReadDatabaseContainer(
$db,
).resultSet<i5.$PersonEntityTable>('person_entity'),
).resultSet<i6.$PersonEntityTable>('person_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$PersonEntityTableFilterComposer(
}) => i6.$$PersonEntityTableFilterComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$table: i5.ReadDatabaseContainer(
$db,
).resultSet<i5.$PersonEntityTable>('person_entity'),
).resultSet<i6.$PersonEntityTable>('person_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@@ -269,25 +284,35 @@ class $$AssetFaceEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column),
);
i3.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i3.$$RemoteAssetEntityTableOrderingComposer composer =
i0.ColumnOrderings<bool> get isVisible => $composableBuilder(
column: $table.isVisible,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get deletedAt => $composableBuilder(
column: $table.deletedAt,
builder: (column) => i0.ColumnOrderings(column),
);
i4.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i4.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
referencedTable: i5.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableOrderingComposer(
}) => i4.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$table: i5.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@@ -297,24 +322,24 @@ class $$AssetFaceEntityTableOrderingComposer
return composer;
}
i5.$$PersonEntityTableOrderingComposer get personId {
final i5.$$PersonEntityTableOrderingComposer composer = $composerBuilder(
i6.$$PersonEntityTableOrderingComposer get personId {
final i6.$$PersonEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.personId,
referencedTable: i4.ReadDatabaseContainer(
referencedTable: i5.ReadDatabaseContainer(
$db,
).resultSet<i5.$PersonEntityTable>('person_entity'),
).resultSet<i6.$PersonEntityTable>('person_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$PersonEntityTableOrderingComposer(
}) => i6.$$PersonEntityTableOrderingComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$table: i5.ReadDatabaseContainer(
$db,
).resultSet<i5.$PersonEntityTable>('person_entity'),
).resultSet<i6.$PersonEntityTable>('person_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@@ -372,25 +397,31 @@ class $$AssetFaceEntityTableAnnotationComposer
builder: (column) => column,
);
i3.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i3.$$RemoteAssetEntityTableAnnotationComposer composer =
i0.GeneratedColumn<bool> get isVisible =>
$composableBuilder(column: $table.isVisible, builder: (column) => column);
i0.GeneratedColumn<DateTime> get deletedAt =>
$composableBuilder(column: $table.deletedAt, builder: (column) => column);
i4.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i4.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
referencedTable: i5.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableAnnotationComposer(
}) => i4.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$table: i5.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@@ -400,24 +431,24 @@ class $$AssetFaceEntityTableAnnotationComposer
return composer;
}
i5.$$PersonEntityTableAnnotationComposer get personId {
final i5.$$PersonEntityTableAnnotationComposer composer = $composerBuilder(
i6.$$PersonEntityTableAnnotationComposer get personId {
final i6.$$PersonEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.personId,
referencedTable: i4.ReadDatabaseContainer(
referencedTable: i5.ReadDatabaseContainer(
$db,
).resultSet<i5.$PersonEntityTable>('person_entity'),
).resultSet<i6.$PersonEntityTable>('person_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$PersonEntityTableAnnotationComposer(
}) => i6.$$PersonEntityTableAnnotationComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$table: i5.ReadDatabaseContainer(
$db,
).resultSet<i5.$PersonEntityTable>('person_entity'),
).resultSet<i6.$PersonEntityTable>('person_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@@ -468,6 +499,8 @@ class $$AssetFaceEntityTableTableManager
i0.Value<int> boundingBoxX2 = const i0.Value.absent(),
i0.Value<int> boundingBoxY2 = const i0.Value.absent(),
i0.Value<String> sourceType = const i0.Value.absent(),
i0.Value<bool> isVisible = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
}) => i1.AssetFaceEntityCompanion(
id: id,
assetId: assetId,
@@ -479,6 +512,8 @@ class $$AssetFaceEntityTableTableManager
boundingBoxX2: boundingBoxX2,
boundingBoxY2: boundingBoxY2,
sourceType: sourceType,
isVisible: isVisible,
deletedAt: deletedAt,
),
createCompanionCallback:
({
@@ -492,6 +527,8 @@ class $$AssetFaceEntityTableTableManager
required int boundingBoxX2,
required int boundingBoxY2,
required String sourceType,
i0.Value<bool> isVisible = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
}) => i1.AssetFaceEntityCompanion.insert(
id: id,
assetId: assetId,
@@ -503,6 +540,8 @@ class $$AssetFaceEntityTableTableManager
boundingBoxX2: boundingBoxX2,
boundingBoxY2: boundingBoxY2,
sourceType: sourceType,
isVisible: isVisible,
deletedAt: deletedAt,
),
withReferenceMapper: (p0) => p0
.map(
@@ -709,6 +748,33 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _isVisibleMeta = const i0.VerificationMeta(
'isVisible',
);
@override
late final i0.GeneratedColumn<bool> isVisible = i0.GeneratedColumn<bool>(
'is_visible',
aliasedName,
false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_visible" IN (0, 1))',
),
defaultValue: const i3.Constant(true),
);
static const i0.VerificationMeta _deletedAtMeta = const i0.VerificationMeta(
'deletedAt',
);
@override
late final i0.GeneratedColumn<DateTime> deletedAt =
i0.GeneratedColumn<DateTime>(
'deleted_at',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [
id,
@@ -721,6 +787,8 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
boundingBoxX2,
boundingBoxY2,
sourceType,
isVisible,
deletedAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -824,6 +892,18 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
} else if (isInserting) {
context.missing(_sourceTypeMeta);
}
if (data.containsKey('is_visible')) {
context.handle(
_isVisibleMeta,
isVisible.isAcceptableOrUnknown(data['is_visible']!, _isVisibleMeta),
);
}
if (data.containsKey('deleted_at')) {
context.handle(
_deletedAtMeta,
deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta),
);
}
return context;
}
@@ -873,6 +953,14 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
i0.DriftSqlType.string,
data['${effectivePrefix}source_type'],
)!,
isVisible: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_visible'],
)!,
deletedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}deleted_at'],
),
);
}
@@ -899,6 +987,8 @@ class AssetFaceEntityData extends i0.DataClass
final int boundingBoxX2;
final int boundingBoxY2;
final String sourceType;
final bool isVisible;
final DateTime? deletedAt;
const AssetFaceEntityData({
required this.id,
required this.assetId,
@@ -910,6 +1000,8 @@ class AssetFaceEntityData extends i0.DataClass
required this.boundingBoxX2,
required this.boundingBoxY2,
required this.sourceType,
required this.isVisible,
this.deletedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -926,6 +1018,10 @@ class AssetFaceEntityData extends i0.DataClass
map['bounding_box_x2'] = i0.Variable<int>(boundingBoxX2);
map['bounding_box_y2'] = i0.Variable<int>(boundingBoxY2);
map['source_type'] = i0.Variable<String>(sourceType);
map['is_visible'] = i0.Variable<bool>(isVisible);
if (!nullToAbsent || deletedAt != null) {
map['deleted_at'] = i0.Variable<DateTime>(deletedAt);
}
return map;
}
@@ -945,6 +1041,8 @@ class AssetFaceEntityData extends i0.DataClass
boundingBoxX2: serializer.fromJson<int>(json['boundingBoxX2']),
boundingBoxY2: serializer.fromJson<int>(json['boundingBoxY2']),
sourceType: serializer.fromJson<String>(json['sourceType']),
isVisible: serializer.fromJson<bool>(json['isVisible']),
deletedAt: serializer.fromJson<DateTime?>(json['deletedAt']),
);
}
@override
@@ -961,6 +1059,8 @@ class AssetFaceEntityData extends i0.DataClass
'boundingBoxX2': serializer.toJson<int>(boundingBoxX2),
'boundingBoxY2': serializer.toJson<int>(boundingBoxY2),
'sourceType': serializer.toJson<String>(sourceType),
'isVisible': serializer.toJson<bool>(isVisible),
'deletedAt': serializer.toJson<DateTime?>(deletedAt),
};
}
@@ -975,6 +1075,8 @@ class AssetFaceEntityData extends i0.DataClass
int? boundingBoxX2,
int? boundingBoxY2,
String? sourceType,
bool? isVisible,
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
}) => i1.AssetFaceEntityData(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
@@ -986,6 +1088,8 @@ class AssetFaceEntityData extends i0.DataClass
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
sourceType: sourceType ?? this.sourceType,
isVisible: isVisible ?? this.isVisible,
deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt,
);
AssetFaceEntityData copyWithCompanion(i1.AssetFaceEntityCompanion data) {
return AssetFaceEntityData(
@@ -1013,6 +1117,8 @@ class AssetFaceEntityData extends i0.DataClass
sourceType: data.sourceType.present
? data.sourceType.value
: this.sourceType,
isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible,
deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt,
);
}
@@ -1028,7 +1134,9 @@ class AssetFaceEntityData extends i0.DataClass
..write('boundingBoxY1: $boundingBoxY1, ')
..write('boundingBoxX2: $boundingBoxX2, ')
..write('boundingBoxY2: $boundingBoxY2, ')
..write('sourceType: $sourceType')
..write('sourceType: $sourceType, ')
..write('isVisible: $isVisible, ')
..write('deletedAt: $deletedAt')
..write(')'))
.toString();
}
@@ -1045,6 +1153,8 @@ class AssetFaceEntityData extends i0.DataClass
boundingBoxX2,
boundingBoxY2,
sourceType,
isVisible,
deletedAt,
);
@override
bool operator ==(Object other) =>
@@ -1059,7 +1169,9 @@ class AssetFaceEntityData extends i0.DataClass
other.boundingBoxY1 == this.boundingBoxY1 &&
other.boundingBoxX2 == this.boundingBoxX2 &&
other.boundingBoxY2 == this.boundingBoxY2 &&
other.sourceType == this.sourceType);
other.sourceType == this.sourceType &&
other.isVisible == this.isVisible &&
other.deletedAt == this.deletedAt);
}
class AssetFaceEntityCompanion
@@ -1074,6 +1186,8 @@ class AssetFaceEntityCompanion
final i0.Value<int> boundingBoxX2;
final i0.Value<int> boundingBoxY2;
final i0.Value<String> sourceType;
final i0.Value<bool> isVisible;
final i0.Value<DateTime?> deletedAt;
const AssetFaceEntityCompanion({
this.id = const i0.Value.absent(),
this.assetId = const i0.Value.absent(),
@@ -1085,6 +1199,8 @@ class AssetFaceEntityCompanion
this.boundingBoxX2 = const i0.Value.absent(),
this.boundingBoxY2 = const i0.Value.absent(),
this.sourceType = const i0.Value.absent(),
this.isVisible = const i0.Value.absent(),
this.deletedAt = const i0.Value.absent(),
});
AssetFaceEntityCompanion.insert({
required String id,
@@ -1097,6 +1213,8 @@ class AssetFaceEntityCompanion
required int boundingBoxX2,
required int boundingBoxY2,
required String sourceType,
this.isVisible = const i0.Value.absent(),
this.deletedAt = const i0.Value.absent(),
}) : id = i0.Value(id),
assetId = i0.Value(assetId),
imageWidth = i0.Value(imageWidth),
@@ -1117,6 +1235,8 @@ class AssetFaceEntityCompanion
i0.Expression<int>? boundingBoxX2,
i0.Expression<int>? boundingBoxY2,
i0.Expression<String>? sourceType,
i0.Expression<bool>? isVisible,
i0.Expression<DateTime>? deletedAt,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
@@ -1129,6 +1249,8 @@ class AssetFaceEntityCompanion
if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2,
if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2,
if (sourceType != null) 'source_type': sourceType,
if (isVisible != null) 'is_visible': isVisible,
if (deletedAt != null) 'deleted_at': deletedAt,
});
}
@@ -1143,6 +1265,8 @@ class AssetFaceEntityCompanion
i0.Value<int>? boundingBoxX2,
i0.Value<int>? boundingBoxY2,
i0.Value<String>? sourceType,
i0.Value<bool>? isVisible,
i0.Value<DateTime?>? deletedAt,
}) {
return i1.AssetFaceEntityCompanion(
id: id ?? this.id,
@@ -1155,6 +1279,8 @@ class AssetFaceEntityCompanion
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
sourceType: sourceType ?? this.sourceType,
isVisible: isVisible ?? this.isVisible,
deletedAt: deletedAt ?? this.deletedAt,
);
}
@@ -1191,6 +1317,12 @@ class AssetFaceEntityCompanion
if (sourceType.present) {
map['source_type'] = i0.Variable<String>(sourceType.value);
}
if (isVisible.present) {
map['is_visible'] = i0.Variable<bool>(isVisible.value);
}
if (deletedAt.present) {
map['deleted_at'] = i0.Variable<DateTime>(deletedAt.value);
}
return map;
}
@@ -1206,7 +1338,9 @@ class AssetFaceEntityCompanion
..write('boundingBoxY1: $boundingBoxY1, ')
..write('boundingBoxX2: $boundingBoxX2, ')
..write('boundingBoxY2: $boundingBoxY2, ')
..write('sourceType: $sourceType')
..write('sourceType: $sourceType, ')
..write('isVisible: $isVisible, ')
..write('deletedAt: $deletedAt')
..write(')'))
.toString();
}

View File

@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 19;
int get schemaVersion => 20;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -226,6 +226,10 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId);
},
from19To20: (m, v20) async {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
},
),
);

View File

@@ -8360,6 +8360,550 @@ final class Schema19 extends i0.VersionedSchema {
);
}
final class Schema20 extends i0.VersionedSchema {
Schema20({required super.database}) : super(version: 20);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxRemoteAlbumOwnerId,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 localAssetEntity = Shape26(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
'idx_remote_album_owner_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape29 assetFaceEntity = Shape29(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
_column_102,
_column_18,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashedLocalAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape29 extends i0.VersionedTable {
Shape29({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get personId =>
columnsByName['person_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get imageWidth =>
columnsByName['image_width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get imageHeight =>
columnsByName['image_height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxX1 =>
columnsByName['bounding_box_x1']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxY1 =>
columnsByName['bounding_box_y1']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxX2 =>
columnsByName['bounding_box_x2']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxY2 =>
columnsByName['bounding_box_y2']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get sourceType =>
columnsByName['source_type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isVisible =>
columnsByName['is_visible']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_visible',
aliasedName,
false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_visible" IN (0, 1))',
),
defaultValue: const CustomExpression('1'),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -8379,6 +8923,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -8472,6 +9017,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from18To19(migrator, schema);
return 19;
case 19:
final schema = Schema20(database: database);
final migrator = i1.Migrator(database, schema);
await from19To20(migrator, schema);
return 20;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -8497,6 +9047,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -8517,5 +9068,6 @@ i1.OnUpgrade stepByStep({
from16To17: from16To17,
from17To18: from17To18,
from18To19: from18To19,
from19To20: from19To20,
),
);

View File

@@ -184,7 +184,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
if (keepFavorites) {
whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false);
whereClause =
whereClause & _db.localAssetEntity.isFavorite.equals(false) & _db.remoteAssetEntity.isFavorite.equals(false);
}
query.where(whereClause);

View File

@@ -16,9 +16,15 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
}
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
final query = _db.select(_db.assetFaceEntity).join([
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
])..where(_db.assetFaceEntity.assetId.equals(assetId) & _db.personEntity.isHidden.equals(false));
final query =
_db.select(_db.assetFaceEntity).join([
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
])..where(
_db.assetFaceEntity.assetId.equals(assetId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull() &
_db.personEntity.isHidden.equals(false),
);
return query.map((row) {
final person = row.readTable(_db.personEntity);
@@ -39,7 +45,9 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
..where(
people.isHidden.equals(false) &
assets.deletedAt.isNull() &
assets.visibility.equalsValue(AssetVisibility.timeline),
assets.visibility.equalsValue(AssetVisibility.timeline) &
faces.isVisible.equals(true) &
faces.deletedAt.isNull(),
)
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not())
..orderBy([

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -321,26 +323,32 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}).watchSingleOrNull();
}
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
Future<List<String>> getSortedAlbumIds(List<String> albumIds, {required AssetDateAggregation aggregation}) async {
if (albumIds.isEmpty) return [];
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
}
final jsonIds = jsonEncode(albumIds);
final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX';
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
final rows = await _db
.customSelect(
'''
SELECT
raae.album_id,
$sqlAgg(rae.local_date_time) AS asset_date
FROM json_each(?) ids
INNER JOIN remote_album_asset_entity raae
ON raae.album_id = ids.value
INNER JOIN remote_asset_entity rae
ON rae.id = raae.asset_id
GROUP BY raae.album_id
ORDER BY asset_date ASC
''',
variables: [Variable<String>(jsonIds)],
readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity},
)
.get();
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
return rows.map((row) => row.read<String>('album_id')).toList();
}
Future<int> getCount() {

View File

@@ -35,6 +35,7 @@ class SearchApiRepository extends ApiRepository {
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
page: page,
size: 100,
@@ -59,6 +60,7 @@ class SearchApiRepository extends ApiRepository {
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
page: page,
size: 1000,

View File

@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -25,6 +26,7 @@ class SyncApiRepository {
Future<void> streamChanges(
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
required SemVer serverVersion,
Function()? onReset,
int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
@@ -64,7 +66,8 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1,
SyncRequestType.assetFacesV1,
if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2,
],
reset: shouldReset,
).toJson(),
@@ -190,6 +193,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.personV1: SyncPersonV1.fromJson,
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
};

View File

@@ -652,6 +652,37 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetFacesV2(Iterable<SyncAssetFaceV2> data) async {
try {
await _db.batch((batch) {
for (final assetFace in data) {
final companion = AssetFaceEntityCompanion(
assetId: Value(assetFace.assetId),
personId: Value(assetFace.personId),
imageWidth: Value(assetFace.imageWidth),
imageHeight: Value(assetFace.imageHeight),
boundingBoxX1: Value(assetFace.boundingBoxX1),
boundingBoxY1: Value(assetFace.boundingBoxY1),
boundingBoxX2: Value(assetFace.boundingBoxX2),
boundingBoxY2: Value(assetFace.boundingBoxY2),
sourceType: Value(assetFace.sourceType),
deletedAt: Value(assetFace.deletedAt),
isVisible: Value(assetFace.isVisible),
);
batch.insert(
_db.assetFaceEntity,
companion.copyWith(id: Value(assetFace.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetFacesV2', error, stack);
rethrow;
}
}
Future<void> deleteAssetFacesV1(Iterable<SyncAssetFaceDeleteV1> data) async {
try {
await _db.batch((batch) {

View File

@@ -0,0 +1,17 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:openapi/api.dart';
final tagsApiRepositoryProvider = Provider<TagsApiRepository>(
(ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi),
);
class TagsApiRepository extends ApiRepository {
final TagsApi _api;
const TagsApiRepository(this._api);
Future<List<TagResponseDto>?> getAllTags() async {
return await _api.getAllTags();
}
}

View File

@@ -323,6 +323,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
groupBy: groupBy,
origin: TimelineOrigin.archive,
joinLocal: true,
);
TimelineQuery locked(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
@@ -421,7 +422,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId),
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
return query.map((row) {
@@ -446,7 +449,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId),
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
)
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
@@ -476,7 +481,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId),
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);

View File

@@ -214,6 +214,7 @@ class SearchFilter {
String? ocr;
String? language;
String? assetId;
List<String>? tagIds;
Set<PersonDto> people;
SearchLocationFilter location;
SearchCameraFilter camera;
@@ -231,6 +232,7 @@ class SearchFilter {
this.ocr,
this.language,
this.assetId,
this.tagIds,
required this.people,
required this.location,
required this.camera,
@@ -246,6 +248,7 @@ class SearchFilter {
(description == null || (description!.isEmpty)) &&
(assetId == null || (assetId!.isEmpty)) &&
(ocr == null || (ocr!.isEmpty)) &&
(tagIds ?? []).isEmpty &&
people.isEmpty &&
location.country == null &&
location.state == null &&
@@ -269,6 +272,7 @@ class SearchFilter {
String? ocr,
String? assetId,
Set<PersonDto>? people,
List<String>? tagIds,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,
@@ -290,12 +294,13 @@ class SearchFilter {
display: display ?? this.display,
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
tagIds: tagIds ?? this.tagIds,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
}
@override
@@ -309,6 +314,7 @@ class SearchFilter {
other.ocr == ocr &&
other.assetId == assetId &&
other.people == people &&
other.tagIds == tagIds &&
other.location == location &&
other.camera == camera &&
other.date == date &&
@@ -326,6 +332,7 @@ class SearchFilter {
ocr.hashCode ^
assetId.hashCode ^
people.hashCode ^
tagIds.hashCode ^
location.hashCode ^
camera.hashCode ^
date.hashCode ^

View File

@@ -14,6 +14,7 @@ class SharedLink {
final String key;
final bool showMetadata;
final SharedLinkSource type;
final String? slug;
const SharedLink({
required this.id,
@@ -27,6 +28,7 @@ class SharedLink {
required this.key,
required this.showMetadata,
required this.type,
required this.slug,
});
SharedLink copyWith({
@@ -41,6 +43,7 @@ class SharedLink {
String? key,
bool? showMetadata,
SharedLinkSource? type,
String? slug,
}) {
return SharedLink(
id: id ?? this.id,
@@ -54,6 +57,7 @@ class SharedLink {
key: key ?? this.key,
showMetadata: showMetadata ?? this.showMetadata,
type: type ?? this.type,
slug: slug ?? this.slug,
);
}
@@ -66,6 +70,7 @@ class SharedLink {
expiresAt = dto.expiresAt,
key = dto.key,
showMetadata = dto.showMetadata,
slug = dto.slug,
type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM
? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
@@ -78,7 +83,7 @@ class SharedLink {
@override
String toString() =>
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)';
@override
bool operator ==(Object other) =>
@@ -94,7 +99,8 @@ class SharedLink {
other.expiresAt == expiresAt &&
other.key == key &&
other.showMetadata == showMetadata &&
other.type == type;
other.type == type &&
other.slug == slug;
@override
int get hashCode =>
@@ -108,5 +114,6 @@ class SharedLink {
expiresAt.hashCode ^
key.hashCode ^
showMetadata.hashCode ^
type.hashCode;
type.hashCode ^
slug.hashCode;
}

View File

@@ -221,8 +221,37 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) {
handleSwipeUpDown(details);
},
onTapDown: (_, __, ___) {
ref.read(showControlsProvider.notifier).toggle();
onTapDown: (ctx, tapDownDetails, _) {
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
if (!tapToNavigate) {
ref.read(showControlsProvider.notifier).toggle();
return;
}
double tapX = tapDownDetails.globalPosition.dx;
double screenWidth = ctx.width;
// We want to change images if the user taps in the leftmost or
// rightmost quarter of the screen
bool tappedLeftSide = tapX < screenWidth / 4;
bool tappedRightSide = tapX > screenWidth * (3 / 4);
int? currentPage = controller.page?.toInt();
int maxPage = renderList.totalAssets - 1;
if (tappedLeftSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != 0) {
controller.jumpToPage(currentPage - 1);
}
} else if (tappedRightSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != maxPage) {
controller.jumpToPage(currentPage + 1);
}
} else {
ref.read(showControlsProvider.notifier).toggle();
}
},
onLongPressStart: asset.isMotionPhoto
? (_, __, ___) {

View File

@@ -109,9 +109,43 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
if (context.router.current.name == SplashScreenRoute.name) {
final needBetaMigration = Store.get(StoreKey.needBetaMigration, false);
if (needBetaMigration) {
bool migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("New Timeline Experience"),
content: const Text(
"The old timeline has been deprecated and will be removed in an upcoming release. Would you like to switch to the new timeline now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
if (migrate != true) {
migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Are you sure?"),
content: const Text(
"If you choose to remain on the old timeline, you will be automatically migrated to the new timeline in an upcoming release. Would you like to switch now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
}
await Store.put(StoreKey.needBetaMigration, false);
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
return;
if (migrate) {
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
return;
}
}
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));

View File

@@ -1,6 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -12,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_converter.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:path/path.dart' as p;
@@ -30,27 +29,10 @@ class EditImagePage extends ConsumerWidget {
final bool isEdited;
const EditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image
.resolve(const ImageConfiguration())
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
} else {
completer.completeError('Failed to convert image to bytes');
}
});
}, onError: (exception, stackTrace) => completer.completeError(exception)),
);
return completer.future;
}
Future<void> _saveEditedImage(BuildContext context, Asset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
final Uint8List imageData = await imageToUint8List(image);
await ref
.read(fileMediaRepositoryProvider)
.saveImage(imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg");

View File

@@ -29,6 +29,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final passwordController = useTextEditingController(text: existingLink?.password ?? "");
final slugController = useTextEditingController(text: existingLink?.slug ?? "");
final slugFocusNode = useFocusNode();
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
@@ -108,6 +110,26 @@ class SharedLinkEditPage extends HookConsumerWidget {
);
}
Widget buildSlugField() {
return TextField(
controller: slugController,
enabled: newShareLink.value.isEmpty,
focusNode: slugFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'custom_url'.tr(),
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'custom_url'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
),
onTapOutside: (_) => slugFocusNode.unfocus(),
);
}
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
@@ -261,6 +283,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
allowUpload: allowUpload.value,
description: descriptionController.text.isEmpty ? null : descriptionController.text,
password: passwordController.text.isEmpty ? null : passwordController.text,
slug: slugController.text.isEmpty ? null : slugController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
@@ -274,7 +297,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
}
if (newLink != null && serverUrl != null) {
newShareLink.value = "${serverUrl}share/${newLink.key}";
final hasSlug = newLink.slug?.isNotEmpty == true;
final urlPath = hasSlug ? newLink.slug : newLink.key;
final basePath = hasSlug ? 's' : 'share';
newShareLink.value = "$serverUrl$basePath/$urlPath";
copyLinkToClipboard();
} else if (newLink == null) {
ImmichToast.show(
@@ -292,6 +318,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? meta;
String? desc;
String? password;
String? slug;
DateTime? expiry;
bool? changeExpiry;
@@ -315,6 +342,12 @@ class SharedLinkEditPage extends HookConsumerWidget {
password = passwordController.text;
}
if (slugController.text != (existingLink!.slug ?? "")) {
slug = slugController.text.isEmpty ? null : slugController.text;
} else {
slug = existingLink!.slug;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
changeExpiry = true;
@@ -329,6 +362,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
allowUpload: upload,
description: desc,
password: password,
slug: slug,
expiresAt: expiry,
changeExpiry: changeExpiry,
);
@@ -349,6 +383,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildShowMetaButton(),

View File

@@ -118,7 +118,7 @@ class MapPage extends HookConsumerWidget {
}
// finds the nearest asset marker from the tap point and store it as the selectedMarker
Future<void> onMarkerClicked(Point<double> point, LatLng coords) async {
Future<void> onMarkerClicked(Point<double> point, LatLng _) async {
// Guard map not created
if (mapController.value == null) {
return;

View File

@@ -28,7 +28,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
}
Future<void> onMapClick(Point<num> point, LatLng centre) async {
Future<void> onMapClick(Point<num> _, LatLng centre) async {
selectedLatLng.value = centre;
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
if (marker.value != null) {

View File

@@ -1,100 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart';
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
final children = <Widget>[];
final items = [
(variant: ImmichVariant.filled, title: "Filled Variant"),
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
];
for (final (:variant, :title) in items) {
children.add(Text(title));
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, 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});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Immich UI Showcase')),
body: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _ComponentTitle("IconButton"),
..._showcaseBuilder(
(variant, color) =>
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")],
),
),
],
),
),
),
);
}
}

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
@@ -14,6 +13,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/image_converter.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
@@ -33,23 +33,6 @@ class DriftEditImagePage extends ConsumerWidget {
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image
.resolve(const ImageConfiguration())
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
} else {
completer.completeError('Failed to convert image to bytes');
}
});
}, onError: (exception, stackTrace) => completer.completeError(exception)),
);
return completer.future;
}
void _exitEditing(BuildContext context) {
// this assumes that the only way to get to this page is from the AssetViewerRoute
@@ -58,7 +41,7 @@ class DriftEditImagePage extends ConsumerWidget {
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
final Uint8List imageData = await imageToUint8List(image);
LocalAsset? localAsset;
try {

View File

@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/image_converter.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
@RoutePage()
class ProfilePictureCropPage extends ConsumerStatefulWidget {
final BaseAsset asset;
const ProfilePictureCropPage({super.key, required this.asset});
@override
ConsumerState<ProfilePictureCropPage> createState() => _ProfilePictureCropPageState();
}
class _ProfilePictureCropPageState extends ConsumerState<ProfilePictureCropPage> {
late final CropController _cropController;
bool _isLoading = false;
bool _didInitCropController = false;
@override
void initState() {
super.initState();
_cropController = CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1));
// Lock aspect ratio to 1:1 for circular/square crop
// CropController depends on CropImage initializing its bitmap size.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _didInitCropController) {
return;
}
_didInitCropController = true;
_cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
_cropController.aspectRatio = 1.0;
});
}
@override
void dispose() {
_cropController.dispose();
super.dispose();
}
Future<void> _handleDone() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
});
try {
final croppedImage = await _cropController.croppedImage();
final pngBytes = await imageToUint8List(croppedImage);
final xFile = XFile.fromData(pngBytes, mimeType: 'image/png');
final success = await ref
.read(uploadProfileImageProvider.notifier)
.upload(xFile, fileName: 'profile-picture.png');
if (!context.mounted) return;
if (success) {
final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath;
ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath);
final user = ref.read(currentUserProvider);
if (user != null) {
unawaited(ref.read(currentUserProvider.notifier).refresh());
}
unawaited(ref.read(backupProvider.notifier).updateDiskInfo());
ImmichToast.show(
context: context,
msg: 'profile_picture_set'.tr(),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.success,
);
if (context.mounted) {
unawaited(context.maybePop());
}
} else {
ImmichToast.show(
context: context,
msg: 'errors.unable_to_set_profile_picture'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
} catch (e) {
if (!context.mounted) return;
ImmichToast.show(
context: context,
msg: 'errors.unable_to_set_profile_picture'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
// Create Image widget from asset
final image = Image(image: getFullImageProvider(widget.asset));
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("set_profile_picture".tr()),
leading: _isLoading ? null : const ImmichCloseButton(),
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
)
else
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onPressed: _handleDone,
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
child: CropImage(controller: _cropController, image: image, gridColor: Colors.white),
),
),
),
);
},
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@@ -24,6 +25,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/feature_check.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
@@ -62,6 +64,7 @@ class DriftSearchPage extends HookConsumerWidget {
mediaType: preFilter?.mediaType ?? AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}",
assetId: preFilter?.assetId,
tagIds: preFilter?.tagIds ?? [],
),
);
@@ -72,15 +75,14 @@ class DriftSearchPage extends HookConsumerWidget {
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final tagCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
final userPreferences = ref.watch(userMetadataPreferencesProvider);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
@@ -148,10 +150,12 @@ class DriftSearchPage extends HookConsumerWidget {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(people: value);
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '),
style: context.textTheme.labelLarge,
);
final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
if (label.isNotEmpty) {
peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge);
} else {
peopleCurrentFilterWidget.value = null;
}
}
handleClear() {
@@ -177,6 +181,42 @@ class DriftSearchPage extends HookConsumerWidget {
);
}
showTagPicker() {
handleOnSelect(Iterable<Tag> tags) {
filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList());
final label = tags.map((t) => t.value).join(', ');
if (label.isEmpty) {
tagCurrentFilterWidget.value = null;
} else {
tagCurrentFilterWidget.value = Text(
label.isEmpty ? 'tags'.t(context: context) : label,
style: context.textTheme.labelLarge,
);
}
}
handleClear() {
filter.value = filter.value.copyWith(tagIds: []);
tagCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'search_filter_tags_title'.t(context: context),
expanded: true,
onSearch: search,
onClear: handleClear,
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
),
),
);
}
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
@@ -658,6 +698,13 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
),
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
SearchFilterChip(
icon: Icons.sell_outlined,
onTap: showTagPicker,
label: 'tags'.t(context: context),
currentFilter: tagCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.camera_alt_outlined,
onTap: showCameraPicker,
@@ -677,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (isRatingEnabled) ...[
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
),
],
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
@@ -22,6 +23,18 @@ class DeleteTrashActionButton extends ConsumerWidget {
return;
}
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => TrashDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {
return;
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class SetAlbumCoverActionButton extends ConsumerWidget {
final String albumId;
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const SetAlbumCoverActionButton({
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).setAlbumCover(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'album_cover_updated'.t(context: context);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.image_outlined,
label: 'set_as_album_cover'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}

View File

@@ -0,0 +1,35 @@
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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class SetProfilePictureActionButton extends ConsumerWidget {
final BaseAsset asset;
final bool iconOnly;
final bool menuItem;
const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context) {
if (!context.mounted) {
return;
}
context.pushRoute(ProfilePictureCropRoute(asset: asset));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.account_circle_outlined,
label: "set_as_profile_picture".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context),
maxWidth: 100,
);
}
}

View File

@@ -16,8 +16,10 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -29,8 +31,9 @@ enum _DragIntent { none, scroll, dismiss }
class AssetPage extends ConsumerStatefulWidget {
final int index;
final int heroOffset;
final void Function(int direction)? onTapNavigate;
const AssetPage({super.key, required this.index, required this.heroOffset});
const AssetPage({super.key, required this.index, required this.heroOffset, this.onTapNavigate});
@override
ConsumerState createState() => _AssetPageState();
@@ -45,21 +48,18 @@ class _AssetPageState extends ConsumerState<AssetPage> {
late PhotoViewControllerValue _initialPhotoViewState;
bool _blockGestures = false;
bool _showingDetails = false;
bool _isZoomed = false;
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
double _snapOffset = 0.0;
double _lastScrollOffset = 0.0;
DragStartDetails? _dragStart;
_DragIntent _dragIntent = _DragIntent.none;
Drag? _drag;
bool _dragInProgress = false;
bool _shouldPopOnDrag = false;
@override
void initState() {
@@ -80,6 +80,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_proxyScrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
_videoScaleStateNotifier.dispose();
super.dispose();
}
@@ -93,7 +94,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_lastScrollOffset = _proxyScrollController.offset;
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
@@ -107,18 +107,15 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _onScroll() {
final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) {
if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
_viewer.setShowingDetails(false);
}
_lastScrollOffset = offset;
}
void _beginDrag(DragStartDetails details) {
_dragStart = details;
_shouldPopOnDrag = false;
_lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0;
if (_viewController != null) {
_initialPhotoViewState = _viewController!.value;
@@ -137,14 +134,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _updateDrag(DragUpdateDetails details) {
if (_blockGestures) return;
_dragInProgress = true;
if (_dragStart == null) return;
if (_dragIntent == _DragIntent.none) {
_dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) {
< -kTouchSlop => _DragIntent.scroll,
> kTouchSlop => _DragIntent.dismiss,
< 0 => _DragIntent.scroll,
> 0 => _DragIntent.dismiss,
_ => _DragIntent.none,
};
}
@@ -160,16 +155,13 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _endDrag(DragEndDetails details) {
_dragInProgress = false;
if (_dragStart == null) return;
if (_blockGestures) {
_blockGestures = false;
return;
}
final start = _dragStart;
_dragStart = null;
final intent = _dragIntent;
_dragIntent = _DragIntent.none;
_dragStart = null;
switch (intent) {
case _DragIntent.none:
@@ -181,7 +173,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_drag?.end(details);
_drag = null;
case _DragIntent.dismiss:
if (_shouldPopOnDrag) {
const popThreshold = 75.0;
if (details.localPosition.dy - start!.localPosition.dy > popThreshold) {
context.maybePop();
return;
}
@@ -200,11 +193,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
PhotoViewControllerBase controller,
PhotoViewScaleStateController scaleStateController,
) {
_viewController = controller;
if (!_showingDetails && _isZoomed) {
_blockGestures = true;
return;
}
if (!_showingDetails && _isZoomed) return;
_beginDrag(details);
}
@@ -217,12 +206,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _handleDragDown(BuildContext context, Offset delta) {
const dragRatio = 0.2;
const popThreshold = 75.0;
_shouldPopOnDrag = delta.dy > popThreshold;
final distance = delta.dy.abs();
final maxScaleDistance = context.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale;
@@ -235,21 +220,43 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) {
if (!_showingDetails && !_dragInProgress) _viewer.toggleControls();
if (_showingDetails || _dragStart != null) return;
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
if (!tapToNavigate) {
_viewer.toggleControls();
return;
}
final tapX = details.globalPosition.dx;
final screenWidth = context.width;
// Navigate if the user taps in the leftmost or rightmost quarter of the screen
final tappedLeftSide = tapX < screenWidth / 4;
final tappedRightSide = tapX > screenWidth * (3 / 4);
if (tappedLeftSide) {
widget.onTapNavigate?.call(-1);
} else if (tappedRightSide) {
widget.onTapNavigate?.call(1);
} else {
_viewer.toggleControls();
}
}
void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) =>
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed = switch (scaleState) {
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
_ => false,
};
_isZoomed =
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.covering ||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
_viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) {
if (!_dragInProgress) _viewer.setControls(false);
if (_dragStart == null) _viewer.setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
@@ -327,34 +334,33 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
return PhotoView.customChild(
key: ValueKey(displayAsset),
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
heroAttributes: heroAttributes,
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
disableScaleGestures: true,
scaleStateChangedCallback: _onScaleStateChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
tightMode: true,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewer(
child: NativeVideoViewer(
key: ValueKey(displayAsset),
asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image(
key: ValueKey(displayAsset.heroTag),
asset: displayAsset,
image: Image(
key: ValueKey(displayAsset),
image: getFullImageProvider(displayAsset, size: context.sizeData),
fit: BoxFit.contain,
height: context.height,
width: context.width,
alignment: Alignment.center,
),
image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height,
width: context.width,
fit: BoxFit.contain,
alignment: Alignment.center,
),
),
);
@@ -382,9 +388,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final viewportHeight = MediaQuery.heightOf(context);
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
final margin = (viewportHeight - imageHeight) / 2;
final overflowBoxHeight = margin + imageHeight - (kMinInteractiveDimension / 2);
_snapOffset = (margin + imageHeight) - (viewportHeight / 4);
final detailsOffset = (viewportHeight + imageHeight - kMinInteractiveDimension) / 2;
final snapTarget = viewportHeight / 3;
_snapOffset = detailsOffset - snapTarget;
if (_proxyScrollController.hasClients) {
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
@@ -429,7 +436,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: overflowBoxHeight),
SizedBox(height: detailsOffset),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
@@ -438,7 +445,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(minHeight: _snapOffset + viewportHeight - overflowBoxHeight),
child: AssetDetails(minHeight: viewportHeight - snapTarget),
),
),
],

View File

@@ -87,39 +87,47 @@ class AssetViewer extends ConsumerStatefulWidget {
}
class _AssetViewerState extends ConsumerState<AssetViewer> {
late PageController pageController;
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
StreamSubscription? _reloadSubscription;
late final int heroOffset;
bool _assetReloadRequested = false;
int _totalAssets = 0;
late final AssetPreloader _preloader;
KeepAliveLink? _stackChildrenKeepAlive;
bool _assetReloadRequested = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
if (page == null) return;
final target = page + direction;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_pageController.jumpToPage(target);
}
}
@override
void initState() {
super.initState();
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
pageController = PageController(initialPage: widget.initialIndex);
final timelineService = ref.read(timelineServiceProvider);
_totalAssets = timelineService.totalAssets;
_preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted);
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
_reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final asset = ref.read(currentAssetNotifier);
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
_reloadSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
}
@override
void dispose() {
pageController.dispose();
_pageController.dispose();
_preloader.dispose();
_reloadSubscription?.cancel();
_stackChildrenKeepAlive?.close();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@@ -176,26 +184,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider);
_totalAssets = timelineService.totalAssets;
final totalAssets = timelineService.totalAssets;
if (_totalAssets == 0) {
if (totalAssets == 0) {
context.maybePop();
return;
}
var index = pageController.page?.round() ?? 0;
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(currentAssetNotifier);
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
pageController.jumpToPage(index);
_pageController.jumpToPage(index);
}
}
if (index >= _totalAssets) {
index = _totalAssets - 1;
pageController.jumpToPage(index);
if (index >= totalAssets) {
index = totalAssets - 1;
_pageController.jumpToPage(index);
}
if (_assetReloadRequested) {
@@ -264,15 +272,16 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: pageController,
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemCount: ref.read(timelineServiceProvider).totalAssets,
onPageChanged: (index) => _onAssetChanged(index),
itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset),
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
if (!CurrentPlatform.isIOS)

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -25,6 +26,7 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -52,6 +54,8 @@ class NativeVideoViewer extends HookConsumerWidget {
final bool showControls;
final int playbackDelayFactor;
final Widget image;
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
final bool disableScaleGestures;
const NativeVideoViewer({
super.key,
@@ -59,6 +63,8 @@ class NativeVideoViewer extends HookConsumerWidget {
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
});
@override
@@ -138,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget {
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
@@ -313,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget {
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
Size? videoContextSize;
if (videoAspectRatio == null || context == null) {
return null;
}
final contextAspectRatio = context.width / context.height;
if (videoAspectRatio > contextAspectRatio) {
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
} else {
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
}
return videoContextSize;
}
ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
@@ -393,26 +414,31 @@ class NativeVideoViewer extends HookConsumerWidget {
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: Center(
return SizedBox(
width: context.width,
height: context.height,
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain(
key: ValueKey(asset),
child: AspectRatio(
visible: isVisible.value,
child: PhotoView.customChild(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
),
),
),
if (showControls) const Center(child: VideoViewerControls()),
],
if (showControls) const Center(child: VideoViewerControls()),
],
),
);
}

View File

@@ -81,27 +81,35 @@ class VideoViewerControls extends HookConsumerWidget {
}
}
void toggleControlsVisibility() {
if (showBuffering) {
return;
}
if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false);
} else {
showControlsAndStartHideTimer();
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showControlsAndStartHideTimer,
child: AbsorbPointer(
absorbing: !showControls,
behavior: HitTestBehavior.translucent,
onTap: toggleControlsVisibility,
child: IgnorePointer(
ignoring: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
GestureDetector(
onTap: () => ref.read(assetViewerProvider.notifier).setControls(false),
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
],
),

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
@@ -113,6 +114,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum && multiselect.selectedAssets.length == 1)
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
],
slivers: ownsAlbum
? [

View File

@@ -25,7 +25,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
@override
void initState() {
super.initState();
_selectedDate = widget.person.birthDate ?? DateTime.now();
_selectedDate = widget.person.birthDate ?? DateTime(DateTime.now().year - 30, 1, 1);
}
void saveBirthday() async {
@@ -90,6 +90,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
selectedDate: _selectedDate,
locale: context.locale,
minimumDate: DateTime(1800, 1, 1),
maximumDate: DateTime.now(),
onDateTimeChanged: (DateTime value) {
setState(() {
_selectedDate = value;

View File

@@ -29,38 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
class _TimelineRestorationState extends ChangeNotifier {
int? _restoreAssetIndex;
bool _shouldRestoreAssetPosition = false;
int? get restoreAssetIndex => _restoreAssetIndex;
bool get shouldRestoreAssetPosition => _shouldRestoreAssetPosition;
void setRestoreAssetIndex(int? index) {
_restoreAssetIndex = index;
notifyListeners();
}
void setShouldRestoreAssetPosition(bool should) {
_shouldRestoreAssetPosition = should;
notifyListeners();
}
void clearRestoreAssetIndex() {
_restoreAssetIndex = null;
notifyListeners();
}
}
class _TimelineRestorationProvider extends InheritedNotifier<_TimelineRestorationState> {
const _TimelineRestorationProvider({required super.notifier, required super.child});
static _TimelineRestorationState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_TimelineRestorationProvider>()!.notifier!;
}
}
class Timeline extends StatefulWidget {
class Timeline extends StatelessWidget {
const Timeline({
super.key,
this.topSliverWidget,
@@ -74,6 +43,7 @@ class Timeline extends StatefulWidget {
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
this.persistentBottomBar = false,
});
final Widget? topSliverWidget;
@@ -87,26 +57,7 @@ class Timeline extends StatefulWidget {
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
@override
State<Timeline> createState() => _TimelineState();
}
class _TimelineState extends State<Timeline> {
double? _lastWidth;
late final _TimelineRestorationState _restorationState;
@override
void initState() {
super.initState();
_restorationState = _TimelineRestorationState();
}
@override
void dispose() {
_restorationState.dispose();
super.dispose();
}
final bool persistentBottomBar;
@override
Widget build(BuildContext context) {
@@ -114,41 +65,32 @@ class _TimelineState extends State<Timeline> {
resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: LayoutBuilder(
builder: (_, constraints) {
if (_lastWidth != null && _lastWidth != constraints.maxWidth) {
_restorationState.setShouldRestoreAssetPosition(true);
}
_lastWidth = constraints.maxWidth;
return _TimelineRestorationProvider(
notifier: _restorationState,
child: ProviderScope(
key: ValueKey(_lastWidth),
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
showStorageIndicator: widget.showStorageIndicator,
withStack: widget.withStack,
groupBy: widget.groupBy,
),
),
if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
key: const ValueKey('_sliver_timeline'),
topSliverWidget: widget.topSliverWidget,
topSliverWidgetHeight: widget.topSliverWidgetHeight,
appBar: widget.appBar,
bottomSheet: widget.bottomSheet,
withScrubber: widget.withScrubber,
snapToMonth: widget.snapToMonth,
initialScrollOffset: widget.initialScrollOffset,
builder: (_, constraints) => ProviderScope(
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
),
);
},
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
maxWidth: constraints.maxWidth,
),
),
),
);
}
@@ -167,14 +109,15 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true,
this.initialScrollOffset,
this.maxWidth,
});
final Widget? topSliverWidget;
@@ -182,8 +125,10 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth;
final double? initialScrollOffset;
final double? maxWidth;
@override
ConsumerState createState() => _SliverTimelineState();
@@ -202,6 +147,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
int _perRow = 4;
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
int? _restoreAssetIndex;
@override
void initState() {
@@ -220,6 +166,20 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
}
@override
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.maxWidth != oldWidget.maxWidth) {
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final index = _getCurrentAssetIndex(segments);
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
final _ = ref.refresh(timelineArgsProvider);
_restoreAssetIndex = index;
});
}
}
void _onEvent(Event event) {
switch (event) {
case ScrollToTopEvent():
@@ -237,21 +197,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
}
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
void _restoreAssetPosition(_) {
final restorationState = _TimelineRestorationProvider.of(context);
if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return;
if (_restoreAssetIndex == null) return;
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final targetSegment = segments.lastWhereOrNull(
(segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!,
);
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
if (targetSegment != null) {
final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex;
final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
@@ -263,7 +216,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
});
}
});
restorationState.clearRestoreAssetIndex();
_restoreAssetIndex = null;
}
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
int? _getCurrentAssetIndex(List<Segment> segments) {
@@ -404,6 +361,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
final isBottomWidgetVisible =
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
return PopScope(
canPop: !isMultiSelectEnabled,
@@ -470,68 +430,56 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
return PrimaryScrollController(
controller: _scrollController,
child: NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final currentIndex = _getCurrentAssetIndex(segments);
if (currentIndex != null && mounted) {
_TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex);
}
return false;
},
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
final targetAssetIndex = _getCurrentAssetIndex(segments);
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
_restoreAssetIndex = targetAssetIndex;
});
if (newPerRow != _perRow) {
final restorationState = _TimelineRestorationProvider.of(context);
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
});
restorationState.setRestoreAssetIndex(targetAssetIndex);
restorationState.setShouldRestoreAssetPosition(true);
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
}
};
},
),
},
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
}
};
},
child: Stack(
children: [
timeline,
if (!isSelectionMode && isMultiSelectEnabled) ...[
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
),
),
},
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
children: [
timeline,
if (isBottomWidgetVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
),
),
if (isBottomWidgetVisible) widget.bottomSheet!,
],
),
),
),

View File

@@ -343,6 +343,22 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> setAlbumCover(ActionSource source, String albumId) async {
final assets = _getAssets(source);
final asset = assets.first;
if (asset is! RemoteAsset) {
return const ActionResult(count: 1, success: false, error: 'Asset must be remote');
}
try {
await _service.setAlbumCover(albumId, asset.id);
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to set album cover', error, stack);
return ActionResult(count: 1, success: false, error: error.toString());
}
}
Future<ActionResult> updateDescription(ActionSource source, String description) async {
final ids = _getRemoteIdsForSource(source);
if (ids.length != 1) {

View File

@@ -0,0 +1,17 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart';
class TagNotifier extends AsyncNotifier<Set<Tag>> {
@override
Future<Set<Tag>> build() async {
final repo = ref.read(tagsApiRepositoryProvider);
final allTags = await repo.getAllTags();
if (allTags == null) {
return {};
}
return allTags.map((t) => Tag.fromDto(t)).toSet();
}
}
final tagProvider = AsyncNotifierProvider<TagNotifier, Set<Tag>>(TagNotifier.new);

View File

@@ -61,10 +61,10 @@ class UploadProfileImageNotifier extends StateNotifier<UploadProfileImageState>
final UserService _userService;
Future<bool> upload(XFile file) async {
Future<bool> upload(XFile file, {String? fileName}) async {
state = state.copyWith(status: UploadProfileStatus.loading);
var profileImagePath = await _userService.createProfileImage(file.name, await file.readAsBytes());
var profileImagePath = await _userService.createProfileImage(fileName ?? file.name, await file.readAsBytes());
if (profileImagePath != null) {
dPrint(() => "Successfully upload profile image");

View File

@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
@@ -88,7 +88,6 @@ 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';
@@ -107,6 +106,7 @@ import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
@@ -199,6 +199,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: EditImageRoute.page),
AutoRoute(page: CropImageRoute.page),
AutoRoute(page: FilterImageRoute.page),
AutoRoute(page: ProfilePictureCropRoute.page),
CustomRoute(
page: FavoritesRoute.page,
guards: [_authGuard, _duplicateGuard],
@@ -338,7 +339,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
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

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