Compare commits

..

370 Commits

Author SHA1 Message Date
Mert
b66c97b785 fix(mobile): use shared auth for background_downloader (#26911)
shared client for background_downloader on ios
2026-03-13 22:23:07 -05:00
Mert
ff936f901d fix(mobile): duplicate server urls returned (#26864)
remove server url

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-13 22:09:42 -05:00
Min Idzelis
48fe111daa feat(web): improve OCR overlay text fitting, reactivity, and accessibility (#26678)
- Precise font sizing using canvas measureText instead of character-count heuristic
- Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads
- Fix animation delay on resize by using transition-colors instead of transition-all
- Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort
- Show text on focus (same styling as hover) with proper ARIA attributes
2026-03-13 22:04:55 -05:00
bo0tzz
0581b49750 fix: ignore optional headers in pr template check (#26910) 2026-03-13 22:55:00 +00:00
rthrth-svg
2c6d4f3fe1 fix(web): copy yearMonth in MonthGroup to avoid shared object reference with asset (#26890)
Co-authored-by: Min Idzelis <min123@gmail.com>
2026-03-13 22:27:08 +01:00
Belnadifia
55513cd59f feat(server): support IDPs that only send the userinfo in the ID token (#26717)
Co-authored-by: irouply <irouply@secom.fr>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-13 22:14:45 +01:00
bo0tzz
10fa928abe feat: require pull requests to follow template (#26902)
* feat: require pull requests to follow template

* fix: persist-credentials: false
2026-03-13 09:43:00 -05:00
Nathaniel Hourt
e322d44f95 fix: SMTP over TLS (#26893)
Final step on #22833

PReq #22833 is about adding support for SMTP-over-TLS rather than just STARTTLS when sending emails. That PReq adds almost everything; it just forgot to actually pass the flag to Nodemailer at the end.

This adds that last line of code and makes it work correctly (for me, anyways!).

Co-authored-by: Nathaniel <I@nathaniel.land>
2026-03-13 09:41:50 -05:00
Michel Heusschen
c2a279e49e fix(web): keep header fixed on individual shared links (#26892) 2026-03-13 09:40:04 -05:00
Mert
226b9390db fix(mobile): video auth (#26887)
* fix video auth

* update commit
2026-03-13 09:38:21 -05:00
Michel Heusschen
754f072ef9 fix(web): disable drag and drop for internal items (#26897) 2026-03-13 09:37:51 -05:00
luis15pt
c91d8745b4 fix: use correct original URL for 360 video panorama playback (#26831)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:27:44 +01:00
Brandon Wees
f3b7cd6198 refactor: move encoded video to asset files table (#26863)
* refactor: move encoded video to asset files table

* chore: update
2026-03-12 16:15:21 -04:00
Jason Rasmussen
990aff441b fix: add to shared link (#26886) 2026-03-12 16:10:55 -04:00
Daniel Dietzler
001d7d083f refactor: small test factories (#26862) 2026-03-12 14:48:49 -04:00
Michel Heusschen
3fd24e2083 fix(server): restrict individual shared link asset removal to owners (#26868)
* fix(server): restrict individual shared link asset removal to owners

* make open-api
2026-03-12 14:48:00 -04:00
Jason Rasmussen
6bb8f4fcc4 refactor: clean class (#26885) 2026-03-12 14:47:35 -04:00
Jason Rasmussen
d4605b21d9 refactor: external links (#26880) 2026-03-12 14:55:33 +00:00
Jason Rasmussen
3bd37ebbfb refactor: clean class (#26879) 2026-03-12 09:53:46 -05:00
Min Idzelis
5c3777ab46 fix(web): fix zoom touch event handling (#26866)
fix(web): fix zoom touch event handling and add clarifying comments

- Suppress Safari's synthetic dblclick on double-tap which conflicts with zoom-image's touchstart-based zoom
- Add comment explaining pointer-events-none on zoom transform wrapper
- Add comments for touchAction and overflow style overrides
2026-03-12 09:37:29 -05:00
Alex
6c531e0a5a chore: add shadow to video play/pause icon shadow (#26836) 2026-03-11 14:15:31 -05:00
Thomas
471c27cd33 chore(mobile): remove background from asset viewer back button (#26851)
We recently changed the asset viewer to use a gradient. The circle
button looks out of place now.
2026-03-11 14:15:18 -05:00
bo0tzz
4773788a88 chore: more unused release workflow cleanup (#26817) 2026-03-11 20:04:26 +01:00
renovate[bot]
d49d995611 chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-11 20:03:19 +01:00
Snowknight26
0ac3d6a83a fix(web): face selection box position resetting on browser resize (#26766) 2026-03-11 19:38:08 +01:00
Mees Frensel
9996ee12d0 refactor(web): crop area tool (#26843) 2026-03-11 18:58:26 +01:00
Brendan Ngo
0a79dd1228 fix(server): extract make/model from sony video files (#26833)
Co-authored-by: Your Name <brendan.ngo@okendo.io>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-11 17:29:37 +00:00
Mees Frensel
e45308b949 fix(web): exclude emoji from translation string (#26852) 2026-03-11 13:22:59 -04:00
Mert
c403e03a42 fix(mobile): logout on upgrade (#26827)
* use cookiejar

* cookie duping hook

* remove old pref

* handle network switching on logout

* remove bootstrapCookies

* dead code

* fix cast

* use constants

* use new event name

* update api
2026-03-11 12:07:27 -05:00
Luis Nachtigall
e7db3b220d feat(mobile): show animated images in asset viewer (#26614)
* Add support for showing animated images in AssetViewer with AnimatedImageStreamCompleter

* Add GIF overlay to thumbnail tile for animated assets

* formatting

* require isAnimated parameter in image providers for better asset handling

* feat: refactor AnimatedImageStreamCompleter to use streams for codec loading and initial image handling

* formatting

* add isAnimatedImage property to BaseAsset

* remove ApiService.getRequestHeaders() usage
2026-03-11 12:07:06 -05:00
bo0tzz
28d5c169c0 chore: use pokedex-large runner for rocm (#26823) 2026-03-11 11:15:48 -05:00
renovate[bot]
0f2fe656db fix(deps): update typescript-projects (#26812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-11 16:40:41 +01:00
Daniel Dietzler
34ce68095d chore: upgrade to kysely 0.28.11 (#26744) 2026-03-11 16:17:31 +01:00
Min Idzelis
8764a1894b feat: adaptive progressive image loading for photo viewer (#26636)
* feat(web): adaptive progressive image loading for photo viewer

Replace ImageManager with a new AdaptiveImageLoader that progressively
loads images through quality tiers (thumbnail → preview → original).

New components and utilities:
- AdaptiveImage: layered image renderer with thumbhash, thumbnail,
  preview, and original layers with visibility managed by load state
- AdaptiveImageLoader: state machine driving the quality progression
  with per-quality callbacks and error handling
- ImageLayer/Image: low-level image elements with load/error lifecycle
- PreloadManager: preloads adjacent assets for instant navigation
- AlphaBackground/DelayedLoadingSpinner: loading state UI

Zoom is handled via a derived CSS transform applied to the content
wrapper in AdaptiveImage, with the zoom library (zoomTarget: null)
only tracking state without manipulating the DOM directly.

Also adds scaleToCover to container-utils and getAssetUrls to utils.

* fix: don't partially render images in firefox

* add passive loading indicator to asset-viewer

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-11 09:48:46 -05:00
Michel Heusschen
27f69b39b2 fix(server): use correct day ordering in timeline buckets (#26821)
* fix(web): sort timeline day groups received from server

* fix(server): use correct day ordering in timeline buckets
2026-03-11 08:49:35 -04:00
Michel Heusschen
9fc6fbc373 fix(web): restore asset update events in asset viewer (#26845) 2026-03-11 08:46:29 -04:00
Thomas
9fc32b6f7a feat(mobile): use material design 3 slider (#26829)
* feat(mobile): use material design 3 slider

The new slider is easier to use, and looks more modern.

* chore: add shadow to button and text for better visibility

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-11 01:58:01 +00:00
Thomas
4571940a4e fix(mobile): wrap backup error message text (#26834)
Refs: #25022
2026-03-10 20:40:01 -05:00
Thomas
1ceb6d2e21 fix(mobile): use tabular figures in backup page (#26830)
The numbers in the backup page are not monospace, and so changes cause
the layout to shift. Using tabular figures (monospace) will prevent
that.

Refs: #25021
2026-03-10 20:12:33 -05:00
Andreas Heinz
1a4c5d73ac feat(web): add shortcut "p" to open/close the face tag box (#26826) 2026-03-10 23:53:38 +01:00
renovate[bot]
22b43bf4d9 chore(deps): update dependency @types/node to ^24.11.0 (#26808)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 16:46:21 +00:00
Michel Heusschen
45eff1c663 fix(web): prevent unrelated assets from appearing in tag view (#26816) 2026-03-10 17:43:30 +01:00
renovate[bot]
56b8e1b8a9 chore(deps): update docker.io/valkey/valkey:9 docker digest to 3eeb097 (#26807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 17:34:20 +01:00
Thomas
f79c8cf1c1 feat(mobile): consolidate video controls (#26673)
Videos have recently been changed to support zooming, but this can make
the controls in the centre of the screen unergonomic as they will either
stay in the centre when dismissing, or stick to the video when zooming.
Neither is great. We should align the behaviour with other apps which
has the play/pause toggle at the bottom of the screen with the seeker
bar instead.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-10 10:55:31 -05:00
Min Idzelis
8e50d25f45 feat(web): animate zoom toggle with cubicOut easing (#26731) 2026-03-10 10:42:02 -05:00
Michel Heusschen
8222781d1f fix(web): correct tag rounding in search options (#26814) 2026-03-10 15:25:13 +00:00
bo0tzz
08c4594cde chore: remove release-pr workflow (#26742)
It's not being used right now; can always add it back :P
2026-03-09 16:53:13 -04:00
Daniel Dietzler
d325231df2 chore: refactor test factories (#26804) 2026-03-09 20:47:03 +00:00
Daniil Suvorov
f2726606e0 fix(web): context menu overflow (#26760) 2026-03-09 19:47:54 +01:00
Andreas Heinz
0edbca24e4 fix(web): recalculate face bounding boxes (#26737)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-09 18:44:47 +00:00
Snowknight26
4791d9c0c3 fix(web): show the correct cursor at crop bounds when editing an asset (#26748) 2026-03-09 19:39:01 +01:00
Michel Heusschen
a47b232235 fix(web): refresh recent albums sidebar after album changes (#26757) 2026-03-09 17:11:32 +01:00
Luis Nachtigall
df0c86920d fix(mobile): restrict trashed asset migration to Android platform (#26726)
* fix(migration): restrict trashed asset migration to Android platform

* playbackStyle migration add different log depending on platform
2026-03-09 09:56:27 -05:00
Min Idzelis
422111d26e test(e2e): fix flakiness: optimize resetDatabase with TRUNCATE and fix selectDay selector scoping (#26776)
fix(e2e): optimize resetDatabase with TRUNCATE and fix selectDay selector scoping
2026-03-08 01:38:15 -06:00
Min Idzelis
7a83baaf27 feat: responsive video duration in thumbnail (#26770) 2026-03-08 01:37:41 -06:00
Aleksander Pejcic
aaf34fa7d4 feat(ml): enable openvino for cpu (#22948)
* Enable OpenVINO CPU acceleration in Immich

* Remove unnecessary debug log

* Removing checking for device_ids for openvino since cpu will always be available

* Find OpenVINOExecutionProvider index instead of assuming index 0

* Fix openvino tests

* Fix failing test mock. OpenVINO expects provider options, but cuda provide doesn't so use that for mocked tests.

* Support empty provider options in OrtSessions in which case ONNXRuntime will use its own defaults

* Use OpenVINOExecutionProvider for test_sets_provider_options_kwarg

* fix mock

* simplify

* unused variable

---------

Co-authored-by: Aleksander <pejcic@adobe.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-03-07 18:40:43 +00:00
Sergey Katsubo
4a384bca86 fix(server): opus handling as accepted audio codec in transcode policy (#26736)
* Fix opus handling as accepted audio codec in transcode policy

Fix the issue when opus is among accepted audio codecs in transcode policy
(which is default) but it still triggers transcoding because the codec name
from ffprobe (opus) does not match `libopus` literal in Immich.

Make a distinction between a codec name and encoder:
- codec name: switch to `opus` as the audio codec name. This matches what ffprobe
returns for a media file with opus audio.
- encoder: continue using the `libopus` encoder in ffmpeg.

* Add unit tests for accepted audio codecs and for libopus encoder

* Add db migration for ffmpeg.targetAudioCodec opus

* backward compatibility

* tweak

* noisy logs

* full mapping

* make check happy

* mark deprecated

* update api

* indexOf

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-03-07 13:08:42 -05:00
Thomas
dd72ec2621 fix(mobile): correct local asset dimensions (#26677)
* fix(mobile): correct local asset dimensions

We are constraining the size of videos so that they play nicely with
hero animations, and don't stretch in weird ways. This however caused a
regression as we are not account for local assets on Android which have
un-oriented dimensions.

* post-orientation width and height in local sync

* migration

* no need to handle it in asset viewer

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-07 13:07:34 -05:00
Luis Nachtigall
e73686bd76 feat(android): enhance playback style detection using MIME type, reducing glide exposure (#26747)
* feat(android): enhance playback style detection using MIME type

* feat(android): improve playback style detection for GIF and WebP formats

* fix(android): make playback style detection faster

* refactor(android): simplify XMP reading logic for API 29 and below

* update playback style detection documentation

* use DefaultImageHeaderParser instead of all available ones for webp playbackStyle type detection
2026-03-07 10:41:26 -05:00
Snowknight26
6e9a425592 fix(web): asset viewer showing wrong viewer type when hovering on stack thumbnails (#26741) 2026-03-06 21:17:11 +01:00
Thomas
6012d22d98 fix(mobile): incorrect asset dimensions in search (#26725)
Search results use a different provider than the main timeline, and they
appear appear to have diverged a bit. This means that assets can
sometimes look wrong or different in search compared to the main
timeline or albums.
2026-03-05 21:58:58 -06:00
Min Idzelis
abfcffb423 feat(web): toggle zoom on double-click in photo viewer (#26732) 2026-03-05 21:58:09 -06:00
Min Idzelis
ec7246b86f refactor(web): add --font-sans CSS variable for primary font (#26730) 2026-03-06 03:00:37 +00:00
Brandon Wees
9597f8c37f feat(mobile): SyncAssetEditV1 (#26518)
* feat(mobile): SyncAssetEditV1

* fix: websocket handling

* fix: server version requirement

* fix: revert pubspec changes
2026-03-05 13:03:59 -06:00
Alex
7b0deb1fd3 fix: playback style migration (#26718) 2026-03-05 12:51:58 -06:00
Thomas
5ab05e57fa fix(mobile): inconsistent asset details background (#26634)
The background of the photo view does not extend below the height of the
viewport, and so the asset details fade in over black with the photo
view, and the standard surface colour scheme of the scaffold for the
rest. This leads to a janky animation. We can't change the background of
the scaffold to black, as it in turn makes the iOS bouncing scroll
physics cut off incorrectly. The best fix is to remove background
decoration from the photo view, and defer to the parent to colour the
background.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 17:45:21 +00:00
Marvin M
ba3f114625 chore: always use Package Imports (#26630)
* chore: always_use_package_imports

* fix: lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 11:18:01 -06:00
Mees Frensel
9b642633c1 fix(server): clarify transcoding bitrate policy (#26711) 2026-03-05 12:13:29 -05:00
Mert
a05c8c6087 feat(mobile): use shared native client (#25942)
* use shared client in dart

fix android

* websocket integration

platform-side headers

update comment

consistent platform check

tweak websocket handling

support streaming

* redundant logging

* fix proguard

* formatting

* handle onProgress

* support videos on ios

* inline return

* improved ios impl

* cleanup

* sync stopForegroundBackup

* voidify

* future already completed

* stream request on android

* outdated ios ws code

* use `choosePrivateKeyAlias`

* return result

* formatting

* update tests

* redundant check

* handle custom headers

* move completer outside of state

* persist auth

* dispose old socket

* use group id for cookies

* redundant headers

* cache global ref

* handle network switching

* handle basic auth

* apply custom headers immediately

* video player update

* fix

* persist url

* potential logout fix

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-05 11:04:45 -06:00
Mert
35a521c6ec fix(ml): batch size setting (#26524) 2026-03-05 12:01:47 -05:00
Snowknight26
09fabb36b6 fix(web): video stealing focus when it plays again when looping (#26704) 2026-03-05 15:41:27 +00:00
Daniel Dietzler
c259fee309 chore: cleanup vscode settings (#26709) 2026-03-05 08:12:59 -05:00
renovate[bot]
78ba9cbc63 chore(deps): update dependency multer to v2.1.1 [security] (#26705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 10:59:51 +01:00
Michel Heusschen
33d75462c9 fix(web): combobox dropdown positioning in modals (#26707) 2026-03-05 10:58:26 +01:00
Mees Frensel
e9451f10d6 chore(web): small cleanup of timeline month (#26708) 2026-03-05 09:53:20 +00:00
Min Idzelis
480b7e8d65 chore: configure ESLint flat config and auto-fix on save in VSCode settings (#26679) 2026-03-04 14:55:02 -05:00
Thomas
228ac63ab9 feat(mobile): keep search results visible (#26498)
Search results are replaced with a spinner when loading the next page,
which is quite jarring. Search results now remain visible when loading
the next page with a spinner at the bottom. The next page also loads
sooner, which makes it feel a lot smoother.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-04 11:27:11 -06:00
Thomas
7e9da945f6 chore(mobile): simplify asset page scroll (#26635)
In order to scroll smoothly without interfering with the gesture
detector on the photo view, we have an offstate scroll view which we
defer all drags to, and then forward scroll offsets to the real scroll
controller. This works well, but it can be simpler. Instead, we can
create a custom scroll controller on a scroll view with never scrollable
physics, and then forward drag events to that, bypassing the need for a
proxy scroll controller.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-04 10:28:55 -06:00
Thomas
dd03c9c0a9 fix(mobile): add safe area for asset details (#26675) 2026-03-04 09:47:51 -06:00
Mees Frensel
16e4a2b92a fix(docs): we usually don't assign issues (#26691)
Update CONTRIBUTING.md
2026-03-04 09:43:19 -06:00
Andreas Heinz
5caa7e1902 feat(web): bounding box for faces when hovering over the face in photo view (#26667)
* feat(web): when hovering over a face already deteced, display the bounding box also shown when hovering over the person in the details-pane.

* prevent lint error

* fix unused var
2026-03-04 15:27:26 +00:00
Snowknight26
8279e1078a fix(web): download toast showing wrong filename for motion assets (#26689) 2026-03-04 16:22:48 +01:00
Timon
011ecbb43d refactor(web): remove replaceAsset action (#26444) 2026-03-04 09:05:44 -06:00
Min Idzelis
2725c96cb1 chore: add recommended VSCode workspace extensions (#26682) 2026-03-04 09:29:15 -05:00
Daniel Dietzler
3c476b1987 chore: vitest 4 for web, cli, and e2e (#26668) 2026-03-04 14:19:13 +00:00
Snowknight26
5989c9b4aa fix(web): inconsistent asset nav bar state after visiting shared link (#26674) 2026-03-04 08:25:29 -05:00
Min Idzelis
13c4260a1f fix: resolve medium test asset paths relative to file location (#26683) 2026-03-04 08:23:58 -05:00
Min Idzelis
54bc9ddd69 chore: add vitest project names and fix server config root paths (#26684)
Add `name` to all vitest configs matching CI job buckets (server:unit,
server:medium, cli:unit, web:unit, e2e:server, e2e:maintenance) so they
appear as filterable @tags in the Vitest VSCode extension.

Fix `root` in server vitest configs to use an absolute path derived from
`import.meta.url` instead of `'./'`, which resolved relative to the config
file directory (`server/test/`) rather than `server/`, causing test
discovery to fail in the Vitest VSCode extension.
2026-03-04 08:20:43 -05:00
Paul Makles
f94e0fbc39 fix(maintenance mode): wait for valid server config on restart (#26456)
Signed-off-by: izzy <me@insrt.uk>
2026-03-04 11:16:21 +00:00
Nicolò Maria Semprini
5532f669eb feat: improve HEIC, HEIF and JPEG XL browser support detection (#26122)
feat: improve heic, heif and jxl browser support detection
2026-03-03 22:41:51 -05:00
Min Idzelis
e4c24bdec8 chore: enable prettier caching and quiet output (#26681) 2026-03-04 03:34:48 +00:00
Savely Krasovsky
56f14162f6 chore: bump base images manually (#26670) 2026-03-04 00:54:55 +00:00
renovate[bot]
8abbbc49cf chore(deps): update dependency opentofu to v1.11.5 (#26655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 16:53:01 +00:00
Thomas
4eb08eee18 fix(mobile): video state (#26574)
Consolidate video state into a single asset-scoped provider, and reduce
dependency on global state generally. Overall this should fix a few
timing issues and race conditions with videos specifically, and make
future changes in this area easier.
2026-03-03 10:28:07 -06:00
Mees Frensel
0560f98c2d chore(web): clarify locale settings description (#25562) 2026-03-03 12:52:17 +01:00
Brandon Annin
49ad411d50 fix(docs): add ocr to job flow diagram (#26505) 2026-03-03 12:43:59 +01:00
renovate[bot]
2478cc40f4 chore(deps): update dependency terragrunt to v0.99.4 (#26658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 11:42:11 +00:00
Joe Babbitt
44eeb1e088 fix: implement existing withStacked on searchAssetBuilder (#26607)
Co-authored-by: Joe <code@joebabbitt.com>
2026-03-03 11:41:29 +00:00
Min Idzelis
a868ae3ad0 perf: move album fetching into detail panel (#26632) 2026-03-03 12:25:03 +01:00
renovate[bot]
acac0d4f37 chore(deps): update github-actions (#26656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2026-03-03 11:14:12 +00:00
Michel Heusschen
8c40a28fef fix(server): clean up edited thumbnail when deleting asset (#26664) 2026-03-03 12:08:07 +01:00
renovate[bot]
b2081eda1e fix(deps): update typescript-projects (#26657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-03 12:06:22 +01:00
renovate[bot]
9670c853c6 chore(deps): update docker.io/valkey/valkey:9 docker digest to 2bce660 (#26652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 12:02:04 +01:00
renovate[bot]
cc2dacb308 chore(deps): update prom/prometheus docker digest to 4a61322 (#26653)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 11:23:13 +01:00
renovate[bot]
15fc6b18f3 chore(deps): update dependency @types/node to ^24.10.14 (#26654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 09:22:58 +00:00
Mees Frensel
a284e38890 fix(web): timeline and asset viewer RTL support (#26513) 2026-03-03 09:01:54 +01:00
Thomas
05010c3a84 fix(mobile): asset viewer hero animation (#26545)
The image in the photo view has no height, and is therefore entirely
unconstrained. This causes the image to take up the full height of the
viewport during the hero animation, which can make look out of sync. In
some other cases, it can stretch or resize the image to fill the entire
viewport.
2026-03-02 22:26:53 -06:00
Min Idzelis
4da3d68a67 refactor: use keyed each for face bounding boxes (#26648) 2026-03-02 22:16:13 -06:00
Min Idzelis
20c639e52a refactor: extract shared ContentMetrics for overlay position calculations (#26310) 2026-03-02 21:49:56 -06:00
Luis Nachtigall
6deb97d5bc fix(mobile): android detect supported version for special format column (#26633)
* fix(android): detect supported version for special format column

* fix(android): remove unnecessary suppression for new API in special format check

* fix(android): change visibility of hasSpecialFormatColumn method to private
2026-03-02 17:06:35 -05:00
Snowknight26
b282d83e95 fix(web): show shared link download button when logged in (#26629) 2026-03-02 22:00:23 +01:00
Jason Rasmussen
5bc08f8654 refactor: queue names (#26650) 2026-03-02 15:46:26 -05:00
shenlong
f54924d46a refactor: simplify video zooming (#26527)
fix: simplify video zooming

# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-03-02 17:53:49 +00:00
Michel Heusschen
dffe4d1d5c refactor(web): remove resize observer action (#26647) 2026-03-02 14:45:34 +00:00
Min Idzelis
7f47cdd645 feat: enhance face-editor positioning (#26303)
feat: enhance face-editor positioning - less overlap

test: timeline with actual video
2026-03-02 09:44:59 -05:00
Min Idzelis
625b30c50a test: stack editor e2e tests (#26526)
* feat: add responsive layout to broken asset

* test: stack editor e2e tests
2026-03-02 09:43:56 -05:00
Min Idzelis
8619d14eca feat: add responsive layout to broken asset (#26384) 2026-03-02 09:27:40 -05:00
Min Idzelis
062546c168 refactor: rename image cancel method (#26381) 2026-03-02 09:23:20 -05:00
Michel Heusschen
ea668d6b22 refactor(web): convert memory observer to an attachment (#26646) 2026-03-02 09:20:13 -05:00
Michel Heusschen
f06af2c600 refactor(web): dedupe isAllUserOwned logic (#26645) 2026-03-02 09:18:32 -05:00
Snowknight26
9dd2633e0c chore(web): deduplicate storage template examples (#26462) 2026-03-02 12:52:02 +01:00
Mees Frensel
13a514c189 fix(web): small thumbnail issues (#26643) 2026-03-02 12:50:33 +01:00
Mees Frensel
b0c9120bb6 chore: update PWA support (#26491) 2026-03-02 11:35:53 +00:00
Yaros
bc4265416d fix(web): top bar z index on search page (#26582) 2026-03-02 11:33:00 +01:00
shenlong
d4434f2276 fix: reset db from splash screen (#26617)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-03-01 15:23:26 -06:00
Luis Nachtigall
f4e156494f feat(mobile): add playbackStyle to local asset entity and related database schema (#26596)
* feat: add playbackStyle to local asset entity and related database schema

* implement conversion function for playbackStyle in local sync service

* implement conversion function for playbackStyle in local sync service

* refactor: remove deducedPlaybackStyle from TrashedLocalAssetEntityData

* add playbackStyle column to trashed local asset entity

* make playbackStyle non-nullable across the mobile codebase

* Streamline playbackStyle backfill:
- only backfill local assets playbackStyle in flutter/dart code
- only update trashed local assets in db migration

* bump target database version to 23 and update migration logic for playbackStyle

* set playback_style to 0 in merged_asset.drift as its a getter in base asset

* run make pigeon

* Populate playbackStyle for trashed assets during native migration
2026-03-01 14:50:21 -05:00
Min Idzelis
84abad564e fix(server): deduplicate shared links in getAll query (#26395) 2026-03-01 14:41:15 -05:00
renovate[bot]
02d356f5dd chore(deps): update dependency multer to v2.1.0 [security] (#26613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 11:34:11 +01:00
renovate[bot]
e963eedd26 chore(deps): update dependency @sveltejs/kit to v2.53.3 [security] (#26612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 11:33:51 +01:00
renovate[bot]
3da4acfe67 chore(deps): update dependency svelte to v5.53.5 [security] (#26611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 11:22:54 +01:00
Yaros
e06cedb626 fix: hide download action for local/merged assets (#26461)
* fix: hide download action for local/merged assets

* chore: use onlyRemote

* chore: rename hasLocal to onlyLocal
2026-03-01 11:16:45 +05:30
Luis Nachtigall
ac5ef6a56d feat(mobile): add support for encoded image requests in local/remote image APIs (#26584)
* feat(mobile): add support for encoded image requests in local and remote image APIs

* fix(mobile): handle memory cleanup for cancelled image requests

* refactor(mobile): simplify memory management and response handling for encoded image requests

* fix(mobile): correct formatting in cancellation check for image requests

* Apply suggestion from @mertalev

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

* refactor(mobile): rename 'encoded' parameter to 'preferEncoded' for clarity in image request APIs

* fix(mobile): ensure proper resource cleanup for cancelled image requests

* refactor(mobile): streamline codec handling by removing unnecessary descriptor disposal in loadCodec request

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2026-02-28 11:43:58 -05:00
Luis Nachtigall
d6c724b13b feat(mobile): add playbackStyle to native sync API (#26541)
* feat(mobile): add playbackStyle to native sync API

Adds a `playbackStyle` field to `PlatformAsset` in the pigeon sync API so
native platforms can communicate the asset's playback style (image, video,
animated, livePhoto) to Flutter during sync.

- Add `playbackStyleValue` computed property to `PHAsset` extension (iOS)
- Populate `playbackStyle` in `toPlatformAsset()` and the full-sync path
- Update generated Dart/Kotlin/Swift files

* fix(tests): add playbackStyle to local asset test cases

* fix(tests): update playbackStyle to use integer values in local sync tests

* feat(mobile): extend playbackStyle enum to include videoLooping

* Update PHAssetExtensions.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(playback): simplify playbackStyleValue implementation by removing iOS version check

* feat(android): implement proper playbackStyle detection

* add PlatformAssetPlaybackStyle enum

* linting

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-28 03:08:51 +00:00
Hao Xi
aa87d1b9a3 fix: tune up the performance of the getByDayOfYear query. (#26495) 2026-02-27 16:51:19 -05:00
Savely Krasovsky
dc4da4b3d6 feat: update onnxruntime-openvino to 1.24.1 and intel drivers (#26565)
feat: update onnxruntime-openvino to 1.24.1 and intel drivers to the latest version
2026-02-27 16:35:29 -05:00
Marius
7dbd08a747 feat(mobile): add confirmation dialog to permanent delete action (#26442) 2026-02-27 15:49:57 +00:00
Thomas
1d89190f96 fix(mobile): don't cut off top corners of app bar (#26550)
It's not visible normally, but in screenshots and when casting, the top
corners of the app bar are cut off. This should fix that.
2026-02-27 17:39:58 +05:30
Thomas
c2d8400899 fix(mobile): prevent video player from being recreated unnecessarily (#26553)
The changes in #25952 inadvertently removed an optimisation which
prevents the video player from being recreated when the tree changed.
This happens surprisingly often, namely when the hero animation
finishes. The widget is particularly expensive, so recreating it 2-3 in
a short period not only feels sluggish, but also causes the video to
hitch and restart.

The solution is to bring the global key back for the native video
player. Unlike before, we are using a custom global key which compares
the values of hero tags directly. This means we don't need to maintain a
map of hero tags to global keys in the state, and also means we don't
have to pass the global key down multiple layers.

This also fixes #25981.
2026-02-27 17:39:38 +05:30
Mees Frensel
a100a4025e fix(web): handle delete shortcut on shared link page as remove (#26552) 2026-02-27 12:50:06 +01:00
Nikhil Alapati
334fc250d3 fix(server): Live Photo migration bug when album is in template (#25329)
Co-authored-by: Nikhil Alapati <nikhilalapati@meta.com>
2026-02-27 12:46:55 +01:00
Michel Heusschen
28ca5f59fe fix(web): map timeline asset count (#26564) 2026-02-27 12:28:53 +01:00
Thomas
789d82632a fix(mobile): race condition showing details (#26559)
Asset details are prematurely hidden when a drag ends if the simulation
shows that it will close given its current velocity. It makes for a much
more responsible feeling UI. However, this behaviour conflicts with the
logic which determines whether details are showing based on the current
offset. The result is that the details are hidden, then immediately
shown again, and then hidden once it passes the min snap distance
threshold.

This can be fixed by only evaluating the position based logic when a
drag is active, and then inferring upcoming state with a simulation.
2026-02-27 12:12:24 +05:30
Daniel Dietzler
9f9569c152 fix: schema check (#26543) 2026-02-26 13:27:50 -05:00
Jason Rasmussen
fae05270a3 feat: doc links (#26519) 2026-02-26 12:14:17 -05:00
Michel Heusschen
771816f601 feat(web): map timeline sidepanel (#26532)
* feat(web): map timeline panel

* update openapi

* remove #key

* add index on lat/lng
2026-02-26 12:03:23 -05:00
Daniel Dietzler
e25ec4ec17 chore: migrate migration scripts to sql tools (#26537) 2026-02-26 17:59:52 +01:00
Kishor Prins
dd9046508d feat: ROCm 7.2 and MIGraphX support (#26178) 2026-02-26 16:52:26 +00:00
shenlong
177d1c9a30 feat: splash screen error page (#26460)
* feat: splash screen error page

* Update mobile/lib/pages/common/splash_screen.page.dart

Co-authored-by: Alex <alex.tran1502@gmail.com>

* add clear data action

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-26 16:47:28 +00:00
Noel S
ded8d4e2b4 fix(mobile): set correct initial system-ui mode in asset viewer (#26500)
* fix: set correct initial system-ui mode on asset open

* move to function and add details visibility to initial state logic

* switch to ref.read
2026-02-26 10:10:46 -06:00
Mees Frensel
e454c3566b refactor: star rating (#26357)
* refactor: star rating

* transform rating 0 to null in controller dto

* migrate rating 0 to null

* deprecate rating -1

* rating type annotation

* update Rating type
2026-02-26 14:54:20 +01:00
Luis Nachtigall
4c79c3c902 feat(mobile): Prevent premature image cache eviction when higher image loading is enabled (#26208)
* feat(mobile): enhance image loading with error handling and eviction options

* pull request suggestions: remove log and remove useless local implementation
2026-02-25 17:31:37 -05:00
Alex
3bed1b6131 fix: consider DAR when extracting video dimension (#25293)
* feat: consider DAR when extracting video dimension

* move to probe

* comment

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-02-25 21:58:53 +00:00
Brandon Wees
3c9fb651d0 feat(server): SyncAssetEditV1 (#26446)
* feat: SyncAssetEditV1

* fix: audit table import

* fix: sql tools table fetch

* fix: medium tests (wip)

* fix: circ dependency

* chore: finalize tests

* chore: codegen/lint

* fix: code review
2026-02-25 18:12:41 +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
Daniel Dietzler
8f9ea6a171 fix: utc time zone upserts (#26258)
fix: utc timezone upserts
2026-02-17 18:59:52 +01:00
Thomas
3f41916ad7 chore(mobile): fix asset marker icon file name (#26290) 2026-02-17 11:53:44 -05:00
Thomas
5c6433b4ca feat(mobile): inline asset details (#25952)
The existing implementation for showing asset details uses a bottom
sheet, and is not in sync with the preview or scroll intent. Other apps
use inline details, which is much cleaner and feels better to use.
2026-02-17 09:24:34 -06:00
Damien Nozay
06d487782e fix(release): add docker-compose.rootless.yml to released assets (#26261)
* fix(release): add docker-compose files to released assets

Since there is a warning:
"Make sure to use the docker-compose.yml of the current release"

This should apply to other docker-compose files, so it would make sense to release them.

It also makes it slightly easier to get the asset for rootless (e.g., PR 2750).

* release docker-compose.rootless.yml
2026-02-17 12:55:34 +01:00
Min Idzelis
455afbb119 ci: fix formatting task (#26274) 2026-02-17 12:51:15 +01:00
ewinnd
0767ae0c8a fix(docs): remove truenas link from synology community guide (#26277)
* Update synology.md to remove Truenas link

Removed link to Truenas github community repo.

* remove blank line

---------

Co-authored-by: Mees Frensel <33722705+meesfrensel@users.noreply.github.com>
2026-02-17 12:50:11 +01:00
renovate[bot]
a16a00ebd4 fix(deps): update typescript-projects (#26276)
* fix(deps): update typescript-projects

* chore: downgrade kysely

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-02-17 11:50:02 +00:00
renovate[bot]
398b750ef7 chore(deps): update dependency github:extism/js-pdk to v1.6.0 (#26279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 12:49:14 +01:00
renovate[bot]
18bbb5b4db chore(deps): update node.js to v24.13.1 (#26275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 11:45:57 +01:00
renovate[bot]
b3c37905f7 chore(deps): update dependency @types/node to ^24.10.13 (#26273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 11:44:38 +01:00
renovate[bot]
90ef6c4e28 chore(deps): update docker.io/valkey/valkey:9 docker digest to 930b414 (#26272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 11:44:21 +01:00
Michel Heusschen
ceef65154d fix(web): clear cache when asset changes (#26257)
* fix(web): clear cache when asset changes

* formatting
2026-02-17 11:43:08 +01:00
Joren Guillaume
de7b42eb23 chore(docs): Update help channel for developers (#26284)
Update help channel for developers
2026-02-17 11:39:43 +01:00
Min Idzelis
75bdd6a644 fix: development containers init race conditions (#25876) 2026-02-16 18:34:42 -05:00
Michel Heusschen
0da74569f2 fix(web): clear unsaved asset description when changing asset (#26255)
* fix(web): clear unsaved asset description when changing asset

* remove unneeded $derived
2026-02-16 18:25:13 +01:00
Michel Heusschen
cc9c261fd0 fix(web): clear face boxes when switching assets (#26249) 2026-02-16 15:52:34 +01:00
Michel Heusschen
4dccc2082b fix(web): focus tag input when modal opens (#26256) 2026-02-16 14:30:41 +00:00
shenlong
9211013996 fix: bring back timeline args auto-scoping (#26219)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-16 08:20:28 -06:00
Alex
156e3479fa chore: styling tweak profile panel (#26248) 2026-02-16 08:20:01 -06:00
Min Idzelis
19ef196150 chore: quiet down dotenv (#26245) 2026-02-15 22:25:18 -06:00
Thomas
d2682f160e fix(mobile): inherit toolbar opacity (#25694)
Some widgets, like Icon widgets, automatically inherit opacity from the
icon theme in the context. Many other widgets however, do not. The
Immich logo, profile picture, and backup badge are examples of widgets
of this.

All unsupported toolbar widgets have been updated to support inheriting
the opacity from the icon theme.

IconButtons internally animate properties like opacity, which is kind of
nice, but means we have to do more work to replicate that behaviour for
other widgets. In most cases, we can simply use an IconButton widget and
forward the correct opacity. The Immich logo however is not a button,
and therefore we need to use a custom TweenAnimationBuilder.

All widgets are using efficient, native opacity rather than the heavy
Opacity widget.
2026-02-16 09:54:57 +05:30
Nicolas
c9dd8e0a79 feat(mobile): hide search by context/OCR if disabled on server (#25472) (#26063)
* feat(mobile): hide search by context/OCR if disabled on server (#25472)

* revert(mobile): remove changes to old search page

---------

Co-authored-by: Nicolas <nicolasroy@MacBookPro>
2026-02-16 08:11:56 +05:30
Dusan Hlavaty
f6e10afe2b chore(docs): fix discord channel in docs (#26238) 2026-02-15 21:34:02 +01:00
Thomas
5f87047490 feat(mobile): dynamic multi-line album name (#26040)
* feat(mobile): dynamic multi-line album name

Album names are currently limited to a single line, and scroll on
overflow. It would be better if album names were multi-line, and even
better if the font size was dynamic depending on how many lines there
are. The album name should then overflow with an ellipsis.

This is actually quite similar to how Google Photos handles album names.

* lint

---------

Co-authored-by: timonrieger <mail@timonrieger.de>
2026-02-15 22:23:45 +05:30
Daniel Dietzler
75e3b0467a chore: hyperlink contributing file in llm message (#26234) 2026-02-15 10:51:47 +00:00
bo0tzz
df4c25e567 fix: use pull_request_target in close-llm-pr.yml (#26232)
So that it actually has write permissions; this should be safe as it doesn't use any external input.
2026-02-15 11:47:01 +01:00
Michel Heusschen
ff7dca35f5 perf(web): speed up asset selection (#26216) 2026-02-14 15:31:04 -05:00
Alex
49ba833e4c fix(web): Revert "add checkerboard background for transparent images (#26091)" (#26220)
Revert "fix(web): add checkerboard background for transparent images (#26091)"

This reverts commit bc7a1c838c.
2026-02-14 20:25:14 +00:00
Michel Heusschen
9ab887d5d2 perf(web): speed up multi asset operations (#26217) 2026-02-14 15:24:47 -05:00
Min Idzelis
d264e78d3f chore: pnpm workspace protocol for sibling packagages (#26218) 2026-02-14 15:03:08 -05:00
Min Idzelis
2c9d69865c fix: e2e exit 135 (#26214) 2026-02-14 12:51:54 -05:00
bo0tzz
72cefcabaf chore: discourage LLM-generated PRs (#26211)
* chore: discourage LLM-generated PRs

* chore: add reading CONTRIBUTING.md to PR checklist

* chore: workflow to close LLM-generated PRs
2026-02-14 10:40:27 -06:00
Xantin
2fb9f84b56 refactor(i18n): Follow IETF standard (#26171)
* refactor(18n):  Follow IETF standard

Rename zh_SIMPLIFIED to zh_Hans

Makes it easier to merge #21337

* fix(web): zh_SIMPLIFIED -> zh_Hans
2026-02-13 18:47:41 +01:00
Weblate (bot)
434ded92f5 chore(web): update translations (#26167)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/yue_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translation: Immich/immich

Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Happy <59247878+happy2452354@users.noreply.github.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Kuba <kubaant@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: PPNplus <ppnplus@protonmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: Ulices <hasecilu@tuta.io>
Co-authored-by: Yu-Hsuan Liao <EMC521@outlook.com>
Co-authored-by: albanobattistella <albano_battistella@hotmail.com>
Co-authored-by: jcreusand <joan.creusandreu@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Óscar Fernández Díaz <42654671+oscfdezdz@users.noreply.github.com>
2026-02-13 17:45:35 +00:00
agent-steven
bc7a1c838c fix(web): add checkerboard background for transparent images (#26091)
Co-authored-by: steven94kr <rlgns98kr@gmail.com>
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-02-13 17:18:44 +00:00
Daniel Dietzler
7cb355279e chore: remove asset stubs (#26187) 2026-02-13 11:00:31 -05:00
Nykri
ecb09501a5 feat(cli): change progress bar to display file size (#23328)
* Change progress bar to display file size

* Solved lint errors
2026-02-13 10:22:00 -05:00
Michel Heusschen
34eb2e1410 fix(web): timeline multi select group state (#26180) 2026-02-13 08:34:15 -05:00
shenlong
2d6580acd8 feat(mobile): dynamic layout in new timeline (#23837)
* feat(mobile): dynamic layout in new timeline

* simplify _buildAssetRow

* auto dynamic mode on smaller column count

* auto layout on smaller tiles

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-13 09:15:42 +05:30
Yaros
9aa3fe82c1 fix(mobile): inconsistent query for people (#24437)
* fix(mobile): inconsistent query for people

* refactor: implement suggestions

* refactor: refactored query impl suggestions
2026-02-13 09:13:21 +05:30
Arne Schwarck
66733eb4c0 feat: add people deeplink (#25686)
* Change path prefix from '/memories/' to '/people/'

Updated the AndroidManifest.xml to change the path prefix from '/memories/' to '/people/'.
Memories is anyway wrong and was replaced by /memory
and now the people path completes the known deeplinks.

* Add regex for people deep link handling

Add regex for people deep link handling

* Add deep link handling for 'people' route

* fix: missing person route builder method

---------

Co-authored-by: bwees <brandonwees@gmail.com>
2026-02-13 09:13:04 +05:30
Thomas
e5156df4f1 fix(mobile): hide latest version warnings (#26036)
The latest version is already hidden in the server info widget if
disabled (https://github.com/immich-app/immich/pull/25691), however I
did not realise there are more places where this warning is shown. This
hides the warning everywhere, and cleans up the code a bit.
2026-02-13 08:15:25 +05:30
Jason Rasmussen
8ef4e4d452 feat: schema-check (#25904) 2026-02-12 17:59:00 -05:00
Christos Longros
7413356a2f fix: clarify external domain translation (#26009)
* fix: clarify external domain setting is used for emails too (#24950)

* Update i18n/en.json

Co-authored-by: Jason Rasmussen <jason@rasm.me>

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-12 14:01:51 -05:00
Jason Rasmussen
5bf4e9595c refactor: purchase store (#25734) 2026-02-12 13:32:17 -05:00
renovate[bot]
6c0c4b3dda chore(deps): update docker.io/valkey/valkey:9 docker digest to 930b414 (#26087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 12:36:46 -05:00
Yaros
206a208410 feat(web): change link expiration logic & presets (#26064)
* feat(web): link expiration presets

* refactor: implement suggestions

* chore: remove createdAt prop

* fix: tests

* fix: button keys
2026-02-12 18:27:49 +01:00
Jason Rasmussen
72cef8b94b feat: shared link login (#25678) 2026-02-12 12:08:38 -05:00
Jason Rasmussen
81c93101a0 feat: verify permissions (#25647) 2026-02-12 12:08:20 -05:00
Christos Longros
b06c21325e fix(mobile): prevent nav bar label text wrapping (#26011)
* fix(mobile): prevent nav bar label text wrapping (#25921)

* chore: fix format

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-12 16:57:03 +00:00
Bastian Köcher
730b770e67 fix(mobile): timeline orientation & foldable phones handling (#25088)
* [mobile]: Fix timeline handling on foldable phones + ensuring that images are not cut off

This fixes the handling of unfolding the phone while having the application opened. So,
the timeline is correctly rescaled and the current position is kept.
Besides that it fixes a bug with the ordering which lead to images being "cut off" at the right side
of the screen.

* refactor + cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-12 22:20:00 +05:30
Michel Heusschen
b85f6f3fce fix(web): add missing @immich/ui translations (#26143) 2026-02-12 16:42:35 +00:00
Min Idzelis
81f592ca52 feat: remove Cache API, rework preload(), cancel() and fetch() (#25289)
* feat: remove Cache API, rework preload(), cancel() and fetch()

perf - replace broadcast channel with direct postMessage

* remove sw response handling

* review comments
2026-02-12 11:25:20 -05:00
renovate[bot]
a62e8ed179 fix(deps): update typescript-projects (#25549)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-02-12 16:24:04 +00:00
Michel Heusschen
1cf3a80840 fix(web): show correct assets in memory gallery (#26157) 2026-02-12 11:17:09 -05:00
Klenner Martins Barros
9f6dbf710c fix(web): improve api key modal responsiveness (#26151) 2026-02-12 17:13:09 +01:00
Michel Heusschen
f207f99e86 fix(web): prevent event manager from throwing error (#26156) 2026-02-12 11:09:15 -05:00
Thomas
0d35231dfd chore(mobile): cleanup server storage info (#26038)
The server storage info has a lot of whitespace due to the ListTile.
Converting it to be inline makes the styling appear more intentional.

There are also a few semantically relevant list items in the app bar
dialog which have been grouped together.
2026-02-12 21:09:55 +05:30
shenlong
675bbf3ac3 chore: remove unused key and fix casing for recent_albums (#24691)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-12 16:06:22 +01:00
Weblate (bot)
c45450b6ac chore(web): update translations (#26118)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ta/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

Co-authored-by: Anton Palmqvist <apq@users.noreply.hosted.weblate.org>
Co-authored-by: PPNplus <ppnplus@protonmail.com>
Co-authored-by: Rishi <rishikesh.200@gmail.com>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Vivek M <vivekmalhotra004@gmail.com>
Co-authored-by: WellsTsai <dan50907@gmail.com>
Co-authored-by: czlevi7 <czlevi7@gmail.com>
Co-authored-by: jässin <aouanijassin@gmail.com>
Co-authored-by: muziqaz <muziqaz@users.noreply.hosted.weblate.org>
Co-authored-by: pyccl <changcongliang@163.com>
2026-02-12 15:02:07 +00:00
renovate[bot]
fea6e8d9f3 chore(deps): update dependency python-multipart to v0.0.22 [security] (#25559) 2026-02-12 09:32:59 -05:00
renovate[bot]
27ebbab1d9 fix(deps): update dependency pillow to v12 [security] (#26142) 2026-02-12 09:32:17 -05:00
renovate[bot]
4647ecf2ea chore(deps): update machine-learning (#25067) 2026-02-12 09:31:13 -05:00
shenlong
78c8f1d5a9 chore: add indexes for foreign keys (#25925)
* chore: add indexes for foreign keys

* update idx_asset_face_person_id index

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-12 19:58:31 +05:30
shenlong
ec4de54ea2 fix: null local date time in timeline queries (#26133)
* fix: null local date time in timeline queries

* refactor effectiveCreatedAt

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-12 19:58:00 +05:30
Peter Ombodi
420cd5193b fix(mobile): Login routing on Splash screen (#26128)
* fix(mobile): fix Login routing on Splash screen

* fix(mobile): remove _duplicateGuard from the LoginRoute
revert changes in splash_screen

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-02-12 19:57:43 +05:30
Daniel Dietzler
7e0356e227 refactor: more small tests (#26159) 2026-02-12 08:34:32 -05:00
Michel Heusschen
913904f418 fix(web): escape shortcut handling (#26096) 2026-02-11 18:55:28 +01:00
Daniel Dietzler
e54678e0d6 refactor: small tests (#26141) 2026-02-11 11:49:00 -05:00
Daniel Dietzler
222c90b7b7 chore: remove album stubs (#26130) 2026-02-11 09:12:46 -05:00
Michel Heusschen
1c1a000c78 fix(web): use locale for date picker (#26125) 2026-02-11 11:39:30 +01:00
Jason Rasmussen
458d5f0f8f fix: ignore checksum constraint error when logging (#26113) 2026-02-10 20:43:55 +00:00
Jason Rasmussen
4c948647fc chore: asset-job medium tests (#26111) 2026-02-10 13:41:40 -05:00
github-actions
3be8e265cd chore: version v2.5.6 2026-02-10 18:24:40 +00:00
Weblate (bot)
e3c4e0197a chore(web): update translations (#25947)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bn/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de_CH/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/el/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/eo/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/id/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ja/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ka/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/kn/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nn/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sq/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/yue_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Ahmed Khaleel Shihab <ahmed91shihab@gmail.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Arnie97 <arnie97@gmail.com>
Co-authored-by: David Krizak <lavidk@icloud.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Gautam Pai <gautam.gp4207@gmail.com>
Co-authored-by: Gilgalard Elendil <Gilgalard11@gmail.com>
Co-authored-by: Gisle-Andre Larsen <gisleandre@hotmail.com>
Co-authored-by: Guillaume Petit <gpetit@edelzone.fr>
Co-authored-by: Guillermo Ramos Santos <guillermo.ramosantos@gmail.com>
Co-authored-by: HackingAll <hacking.all.YT@gmail.com>
Co-authored-by: Hans Cats <hanscats@gmail.com>
Co-authored-by: Happy <59247878+happy2452354@users.noreply.github.com>
Co-authored-by: Haru Ijima <haruijimakun@gmail.com>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Julius Lehmann <julius.lehmann.privat@gmail.com>
Co-authored-by: Luuk Heijnen <luukheijnen1@gmail.com>
Co-authored-by: MarcSerraPeralta <marcserraperalta@gmail.com>
Co-authored-by: Masani Amin Yasin <langugetranslategay.taps663@silomails.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Olaf Nielsen <solluh@mail.de>
Co-authored-by: Oleksandr Yurov <oyurov@icloud.com>
Co-authored-by: Philipp Frauenfelder <philipp.frauenfelder@gmail.com>
Co-authored-by: Reetryyy <kaxa.kikalishvili.kk@gmail.com>
Co-authored-by: Romo <romo@romo.al>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Skanda <skillwiz94@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Tage Lauritsen <tage@tunenet.dk>
Co-authored-by: Tim Morley <weblate.3919org@timsk.org>
Co-authored-by: Ulices <hasecilu@tuta.io>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Veerasak Kritsanapraphan <veerasak.kritsanapraphan@gmail.com>
Co-authored-by: albanobattistella <albano_battistella@hotmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: chamdim <chamdim@protonmail.com>
Co-authored-by: dvbthien <dvbthien@users.noreply.hosted.weblate.org>
Co-authored-by: ea stanley <hang4ea@gmail.com>
Co-authored-by: muziqaz <muziqaz@users.noreply.hosted.weblate.org>
Co-authored-by: nobo73 <noboru.tanaka@gmail.com>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Óscar Fernández Díaz <42654671+oscfdezdz@users.noreply.github.com>
2026-02-10 18:22:41 +00:00
Nicolò Maria Semprini
1ddb8f0667 feat: jxl browser support detection (#25599) 2026-02-10 12:49:09 -05:00
Alex
adfb003d03 fix: local date time group fall back (#26110)
* fix: local date time group fall back

* remove else clause
2026-02-10 11:47:13 -06:00
Brandon Wees
8c8b11f80c fix: person thumbnail generation on edited assets (#26112)
* fix: person thumbnail generation on edited assets

* chore: sql sync
2026-02-10 11:38:43 -06:00
Claudio
90d554947f docs: update ml-hardware-acceleration.md (#25755)
Update ml-hardware-acceleration.md

Invert the lines about editing the docker-compose.yml file to have users add the tag to the image first, then uncomment the extends section. This should help users follow the instructions as they flow through the YAML file.
2026-02-10 12:28:21 -05:00
Paul Makles
caeba5063b refactor(server): move database restores code into a service (#25918)
* fix(server): use provided database name/username for restore & ensure name is not mangled

fixes #25633

Signed-off-by: izzy <me@insrt.uk>

* chore: add db switch back but with comments

Signed-off-by: izzy <me@insrt.uk>

* refactor: no need to restore database since it's not technically possible
chore: late fallback for username in parameter builder

Signed-off-by: izzy <me@insrt.uk>

* chore: type fix

Signed-off-by: izzy <me@insrt.uk>

* refactor: move db backup code into service

* test: check SQL sent to psql

* chore: remove todo

Signed-off-by: izzy <me@insrt.uk>

---------

Signed-off-by: izzy <me@insrt.uk>
2026-02-10 12:12:27 -05:00
Matthew Momjian
280174026f feat(deployment): rootless compose file (#25931)
rootless deployment
2026-02-10 12:11:44 -05:00
Michel Heusschen
a9e0fa43fa fix: correctly cancel select all assets (#26067) 2026-02-10 11:47:23 -05:00
Jason Rasmussen
e6e56d75e2 fix(web): refresh text (#26071) 2026-02-10 16:42:03 +00:00
Brandon Wees
0886281dd8 fix: create face exif orientation handling (#26108)
* fix: handle exif orientation when creating face

* chore: tests
2026-02-10 11:30:34 -05:00
renovate[bot]
32dea76a92 chore(deps): update dependency @types/node to ^24.10.11 (#26088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 11:24:33 -05:00
Min Idzelis
6af534fe4c feat: run maintenance tests in isolation, share containers between all … (#25856)
* feat: run maintance tests in isolation, share containers between all serial test suites

* refactor: organize files

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-10 11:05:06 -05:00
Daniel Dietzler
71fe9192fd chore: metadata extraction date test (#26102) 2026-02-10 10:05:05 -05:00
Mert
7fa6f617f5 fix(server): thumbnail queueing (#26077)
* fix thumbnail queueing

* add bmp

* other isEdited column
2026-02-10 09:04:03 -06:00
Thomas
c3730c8eab chore(mobile): enable high refresh rate in debug builds (#26085)
I'm testing changes to animations and app performance, and noticed it
felt quite sluggish on a 120hz display. It turns out that high refresh
is disabled in debug builds. It's probably a good idea to enable it so
that it more closely mirrors the production build.
2026-02-10 03:51:33 +00:00
Luis Nachtigall
3462fc434e fix: evict image from cache on error during image loading (#26078) 2026-02-09 17:01:10 -05:00
Luis Nachtigall
561469b826 fix(mobile): handle image stream completion when no image is emitted (#25984)
* Fix image cancellation to be stream-scoped instead of widget-scoped

* fix(OneFramePlaceholderImageStreamCompleter): make onLastListenerRemoved callback synchronous with removing the last listener

* fix(OneFrameMultiImageStreamCompleter): remove unnecessary blank line in code

* fix(OneFramePlaceholderImageStreamCompleter): cancel pending requests when only cache listener remains

* fix(OneFrameMultiImageStreamCompleter): ensure onLastListenerRemoved callback is invoked only once
2026-02-09 16:59:29 -05:00
bo0tzz
937bef9a4d fix: run rocm builds on pokedex (#26062)
* fix: run rocm builds on pokedex

* fix: --parallel 48 (#26065)
2026-02-09 15:06:05 -05:00
dolfje
5f18110e97 fix(web): removing a person in an asset, doesn't remove the asset in … (#26068)
* fix(web): removing a person in an asset, doesn't remove the asset in the persons view (without refresh)

* prettier

---------

Co-authored-by: Nikos Verschore <nikos@uwsoftware.be>
2026-02-09 11:51:02 -06:00
Alex
57485023ae fix: free up space using small batch size to reliably work on Android (#26047)
* fix: free up space delete in small batch

* fix: free up space delete in small batch
2026-02-09 11:48:55 -06:00
Michel Heusschen
8a9b541dd0 fix: slideshow setting dropdown overflow (#26066) 2026-02-09 09:28:47 -06:00
Justin Xiao
25be5fc22d fix(web): prevent context menu from overflowing viewport (#26041)
* fix(web): prevent context menu from overflowing viewport

The context menu used `max-h-dvh` (100% viewport height) as its max height,
but did not account for the menu's top position. When the menu opens at
y > 0, its bottom extends beyond the viewport.

Compute `maxHeight` dynamically based on the menu's top position and apply
it as an inline style, so the menu always fits within the viewport and
scrolls when content exceeds the available space.

* fix: linting

* fix: overflow

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-09 09:26:25 -06:00
Alex
906c38273f fix: profile dialog auto dismiss after opening on iPad (#26046) 2026-02-08 17:30:57 -06:00
Alex
10b2bf7970 fix: iOS slow start (#26043) 2026-02-08 16:28:51 -06:00
Kolin
7cf8a9936a fix(web): display storage unit next to value instead of absolute positioning in admin user page (#25985)
* fix(web): display storage unit next to value instead of absolute positioning in admin user page

* chore: styling

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-02-08 17:22:42 +00:00
Michel Heusschen
59c4a49ffd fix: scroll jump when opening show & hide people (#25932) 2026-02-08 11:19:35 -06:00
Yaros
00486cbcc8 fix(web): toast fixed location (#25966)
fix: toast fixed location
2026-02-08 11:15:09 -06:00
Michel Heusschen
b524d7b6fd fix: reduce queue graph jitter and include paused count (#26023)
fix: reduce queue graph jitter and show paused count
2026-02-08 11:03:59 -06:00
Michel Heusschen
5b705cb723 fix: improve albums page load time on firefox (#26025) 2026-02-08 11:03:17 -06:00
Luis Nachtigall
354dd3cc3c feat(mobile): enhance album sorting functionality with order handling (#24816)
* feat: enhance album sorting functionality with effective order handling

* mobile: formatting

* test: align album sorting order in unit tests with defaultSortOrder

* test(mobile): add reverse order validation for album sorting

* chore(PR): remove OppositeSortOrder Extension and move it directly into SortOrder enum

* refactor: return sorted list directly in album sorting function

* refactor: remove sort_order_extensions.dart
2026-02-07 10:11:37 +05:30
shenlong
57483a1e7f fix: user profile refetched each time on opening app dialog (#25992)
fix: user profile on opening app dialog

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-07 10:10:17 +05:30
Romo
bcea64875f fix: image and video download complete notification shows "file_name" (#25975)
* fix: image and video download complete notification shows "file_name"

* fix lint

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-07 02:56:14 +00:00
Matthew Momjian
84e30abe5d feat(docs): version policy (#25979)
version policy
2026-02-06 20:31:57 -05:00
Jason Rasmussen
e3e243fa2b refactor: tests (#25987) 2026-02-07 00:47:54 +01:00
Jason Rasmussen
b3820c259e refactor: test factories (#25977) 2026-02-06 16:32:50 -05:00
2800 changed files with 470765 additions and 28102 deletions

View File

@@ -0,0 +1,126 @@
{
"name": "Immich - Backend, Frontend and ML",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
"immich-machine-learning"
],
"dockerComposeFile": [
"../docker/docker-compose.dev.yml",
"./server/container-compose-overrides.yml"
],
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"ms-vscode-remote.remote-containers",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"rvest.vs-code-prettier-eslint",
"bluebrown.yamlfmt",
"vkrishna04.cspell-sync",
"vitest.explorer",
"ms-playwright.playwright",
"ms-azuretools.vscode-docker"
],
"settings": {
"tasks": {
"version": "2.0.0",
"tasks": [
{
"label": "Immich API Server (Nest)",
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.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": "folderOpen"
},
"problemMatcher": []
},
{
"label": "Immich Web Server (Vite)",
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.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": "folderOpen"
},
"problemMatcher": []
},
{
"label": "Build Immich CLI",
"type": "shell",
"command": "pnpm --filter cli build:dev"
}
]
}
}
}
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
// https://github.com/devcontainers/features/issues/1466
"moby": false
}
},
"forwardPorts": [3000, 9231, 9230, 2283],
"portsAttributes": {
"3000": {
"label": "Immich - Frontend HTTP",
"description": "The frontend of the Immich project",
"onAutoForward": "openBrowserOnce"
},
"2283": {
"label": "Immich - API Server - HTTP",
"description": "The API server of the Immich project"
},
"9231": {
"label": "Immich - API Server - DEBUG",
"description": "The API server of the Immich project"
},
"9230": {
"label": "Immich - Workers - DEBUG",
"description": "The workers of the Immich project"
}
},
"overrideCommand": true,
"workspaceFolder": "/usr/src/app",
"remoteUser": "root",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}",
// Connection secret for postgres. You should change it to a random password
// Please use only the characters `A-Za-z0-9`, without special characters or spaces
"DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",
// The database username
"DB_USERNAME": "${localEnv:DB_USERNAME:postgres}",
// The database name
"DB_DATABASE_NAME": "${localEnv:DB_DATABASE_NAME:immich}"
}
}

View File

@@ -0,0 +1,34 @@
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:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
immich-web:
env_file: !reset []
immich-machine-learning:
env_file: !reset []
database:
env_file: !reset []
environment: !override
POSTGRES_PASSWORD: ${DB_PASSWORD-postgres}
POSTGRES_USER: ${DB_USERNAME-postgres}
POSTGRES_DB: ${DB_DATABASE_NAME-immich}
POSTGRES_INITDB_ARGS: '--data-checksums'
POSTGRES_HOST_AUTH_METHOD: md5
volumes:
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
redis:
env_file: !reset []
volumes:
upload-devcontainer-volume:
postgres-devcontainer-volume:

View File

@@ -0,0 +1,53 @@
{
"name": "Immich - Mobile",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
"immich-machine-learning"
],
"dockerComposeFile": [
"../../docker/docker-compose.dev.yml",
"./container-compose-overrides.yml"
],
"customizations": {
"vscode": {
"extensions": [
"Dart-Code.dart-code",
"Dart-Code.flutter",
"dcmdev.dcm-vscode-extension",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"svelte.svelte-vscode",
"ms-vscode-remote.remote-containers",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"rvest.vs-code-prettier-eslint",
"bluebrown.yamlfmt",
"vkrishna04.cspell-sync",
"vitest.explorer",
"ms-playwright.playwright",
"ms-azuretools.vscode-docker"
]
}
},
"forwardPorts": [],
"overrideCommand": true,
"workspaceFolder": "/usr/src/app",
"remoteUser": "node",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}",
// Connection secret for postgres. You should change it to a random password
// Please use only the characters `A-Za-z0-9`, without special characters or spaces
"DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",
// The database username
"DB_USERNAME": "${localEnv:DB_USERNAME:postgres}",
// The database name
"DB_DATABASE_NAME": "${localEnv:DB_DATABASE_NAME:immich}"
}
}

View File

@@ -0,0 +1,32 @@
#!/bin/bash
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
export DEV_PORT="${DEV_PORT:-3000}"
IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
log() {
# Display command on console, log with timestamp to file
echo "$*"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >>"$IMMICH_DEVCONTAINER_LOG"
}
run_cmd() {
# Ensure log directory exists
mkdir -p "$(dirname "$IMMICH_DEVCONTAINER_LOG")"
log "$@"
# Execute command: display normally on console, log with timestamps to file
"$@" 2>&1 | tee >(while IFS= read -r line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line" >>"$IMMICH_DEVCONTAINER_LOG"
done)
# Preserve exit status
return "${PIPESTATUS[0]}"
}
export IMMICH_WORKSPACE="/usr/src/app"
log "Found immich workspace in $IMMICH_WORKSPACE"
log ""

View File

@@ -0,0 +1,38 @@
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:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []
immich-machine-learning:
env_file: !reset []
database:
env_file: !reset []
environment: !override
POSTGRES_PASSWORD: ${DB_PASSWORD-postgres}
POSTGRES_USER: ${DB_USERNAME-postgres}
POSTGRES_DB: ${DB_DATABASE_NAME-immich}
POSTGRES_INITDB_ARGS: '--data-checksums'
POSTGRES_HOST_AUTH_METHOD: md5
volumes:
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
redis:
env_file: !reset []
volumes:
upload-devcontainer-volume:
postgres-devcontainer-volume:

View File

@@ -0,0 +1,22 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Preparing Immich Nest API Server"
log ""
export CI=1
run_cmd pnpm --filter immich install
log "Starting Nest API Server"
log ""
cd "${IMMICH_WORKSPACE}/server" || (
log "Immich workspace not found"
exit 1
)
while true; do
run_cmd pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -0,0 +1,29 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
export CI=1
log "Preparing Immich Web Frontend"
log ""
run_cmd pnpm --filter @immich/sdk install
run_cmd pnpm --filter @immich/sdk build
run_cmd pnpm --filter immich-web install
log "Starting Immich Web Frontend"
log ""
cd "${IMMICH_WORKSPACE}/web" || (
log "Immich Workspace not found"
exit 1
)
until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_PORT}/api/server/config"; do
log "Waiting for api server..."
sleep 1
done
while true; do
run_cmd pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port "${DEV_PORT}"
log "Web crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -1,14 +0,0 @@
# Database
DB_HOSTNAME=localhost
DB_PORT=5435
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=app
# Redis
REDIS_HOSTNAME=localhost
REDIS_PORT=6379
# Server
IMMICH_PORT=2283
IMMICH_ENV=development

1
.github/.nvmrc vendored Normal file
View File

@@ -0,0 +1 @@
24.13.1

4
.github/.prettierignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@@ -0,0 +1,33 @@
title: '[Feature] feature-name-goes-here'
labels: ['feature']
body:
- type: markdown
attributes:
value: |
Please use this form to request new feature for Immich.
Stick to only a single feature per request. If you list multiple different features at once,
your request will be closed.
- type: checkboxes
attributes:
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
options:
- label: 'Yes'
- type: textarea
id: feature
attributes:
label: The feature
validations:
required: true
- type: checkboxes
validations:
required: true
attributes:
label: Platform
options:
- label: Server
- label: Web
- label: Mobile

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ['https://buy.immich.app', 'https://immich.store']

119
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@@ -0,0 +1,119 @@
name: Report an issue with Immich
description: Report an issue with Immich
body:
- type: checkboxes
attributes:
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
options:
- label: 'Yes'
- type: markdown
attributes:
value: |
This issue form is for reporting bugs only!
If you have a feature or enhancement request, please use the [feature request][fr] section of our [GitHub Discussions][fr].
[fr]: https://github.com/immich-app/immich/discussions/new?category=feature-request
- type: textarea
validations:
required: true
attributes:
label: The bug
description: >-
Describe the issue you are experiencing here, to communicate to the
maintainers. Tell us what you were trying to do and what happened.
Provide a clear and concise description of what the problem is.
- type: markdown
attributes:
value: |
## Environment
- type: input
validations:
required: true
attributes:
label: The OS that Immich Server is running on
placeholder: Ubuntu 22.10, Debian, Arch...etc
- type: input
id: version
validations:
required: true
attributes:
label: Version of Immich Server
placeholder: v1.0.0
- type: input
validations:
required: true
attributes:
label: Version of Immich Mobile App
placeholder: v1.0.0
- type: checkboxes
validations:
required: true
attributes:
label: Platform with the issue
options:
- label: Server
- label: Web
- label: Mobile
- type: input
attributes:
label: Device make and model
placeholder: Samsung S25 Android 16
- type: textarea
validations:
required: true
attributes:
label: Your docker-compose.yml content
render: YAML
- type: textarea
validations:
required: true
attributes:
label: Your .env content
description: Please provide the redacted .env content of your setup
render: Shell
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: 'How do you trigger this bug? Please walk us through it step by step.'
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description:
Please copy and paste any relevant logs below. (code formatting is
enabled, no need for backticks)
render: shell
validations:
required: false
- type: textarea
attributes:
label: Additional information
description: >
If you have any additional information for us, use the field below.
- type: markdown
attributes:
value: Thank you for submitting the form

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: ✋ I have a question or need support
url: https://discord.immich.app
about: We use GitHub for tracking bugs, please check out our Discord channel for freaky fast support.
- name: 📷 My photo or video has a date, time, or timezone problem
url: https://github.com/immich-app/immich/discussions/12650
about: Upload a sample file to this discussion and we will take a look
- name: 🌟 Feature request
url: https://github.com/immich-app/immich/discussions/new?category=feature-request
about: Please use our GitHub Discussion for making feature requests.
- name: 🫣 I'm unsure where to go
url: https://discord.immich.app
about: If you are unsure where to go, then joining our Discord is recommended; Just ask!

View File

@@ -0,0 +1 @@
blank_pull_request_template_enabled: false

37
.github/labeler.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
cli:
- changed-files:
- any-glob-to-any-file:
- cli/src/**
documentation:
- changed-files:
- any-glob-to-any-file:
- docs/docs/**
- docs/src/**
- docs/static/**
🖥web:
- changed-files:
- any-glob-to-any-file:
- web/src/**
- web/static/**
📱mobile:
- changed-files:
- any-glob-to-any-file:
- mobile/lib/**
- mobile/test/**
🗄server:
- changed-files:
- any-glob-to-any-file:
- server/src/**
- server/test/**
🧠machine-learning:
- changed-files:
- any-glob-to-any-file:
- machine-learning/**
changelog:translation:
- head-branch: ['^chore/translations$']

10
.github/mise.toml vendored Normal file
View File

@@ -0,0 +1,10 @@
[tasks.install]
run = "pnpm install --filter github --frozen-lockfile"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."

9
.github/package.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"scripts": {
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different ."
},
"devDependencies": {
"prettier": "^3.7.4"
}
}

41
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,41 @@
## Description
<!--- Describe your changes in detail -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
Fixes # (issue)
## How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
- [ ] Test A
- [ ] Test B
<details><summary><h2>Screenshots (if appropriate)</h2></summary>
<!-- Images go below this line. -->
</details>
<!-- API endpoint changes (if relevant)
## API Changes
The `/api/something` endpoint is now `/api/something-else`
-->
## Checklist:
- [ ] I have carefully read CONTRIBUTING.md
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation if applicable
- [ ] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
## Please describe to which degree, if any, an LLM was used in creating this pull request.
...

33
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
changelog:
categories:
- title: 🚨 Breaking Changes
labels:
- changelog:breaking-change
- title: 🫥 Deprecated Changes
labels:
- changelog:deprecated
- title: 🔒 Security
labels:
- changelog:security
- title: 🚀 Features
labels:
- changelog:feature
- title: 🌟 Enhancements
labels:
- changelog:enhancement
- title: 🐛 Bug fixes
labels:
- changelog:bugfix
- title: 📚 Documentation
labels:
- changelog:documentation
- title: 🌐 Translations
labels:
- changelog:translation

297
.github/workflows/build-mobile.yml vendored Normal file
View File

@@ -0,0 +1,297 @@
name: Build Mobile
on:
workflow_call:
inputs:
ref:
required: false
type: string
environment:
description: 'Target environment'
required: true
default: 'development'
type: string
secrets:
KEY_JKS:
required: true
ALIAS:
required: true
ANDROID_KEY_PASSWORD:
required: true
ANDROID_STORE_PASSWORD:
required: true
APP_STORE_CONNECT_API_KEY_ID:
required: true
APP_STORE_CONNECT_API_KEY_ISSUER_ID:
required: true
APP_STORE_CONNECT_API_KEY:
required: true
IOS_CERTIFICATE_P12:
required: true
IOS_CERTIFICATE_PASSWORD:
required: true
FASTLANE_TEAM_ID:
required: true
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
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@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
mobile:
- 'mobile/**'
force-filters: |
- '.github/workflows/build-mobile.yml'
force-events: 'workflow_call,workflow_dispatch'
build-sign-android:
name: Build and sign Android
needs: pre-job
permissions:
contents: read
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: mich
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Create the Keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
cache: true
- name: Setup Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
with:
packages: ''
- name: Get Packages
working-directory: ./mobile
run: flutter pub get
- name: Generate translation file
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
working-directory: ./mobile
- name: Generate platform APIs
run: make pigeon
working-directory: ./mobile
- name: Build Android App Bundle
working-directory: ./mobile
env:
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
flutter build apk --debug --split-per-abi --target-platform android-arm64
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: github.ref == 'refs/heads/main'
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
build-sign-ios:
name: Build and sign iOS
needs: pre-job
permissions:
contents: read
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: macos-15
steps:
- name: Select Xcode 26
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
cache: true
- name: Install Flutter dependencies
working-directory: ./mobile
run: flutter pub get
- name: Generate translation files
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
working-directory: ./mobile
- name: Generate platform APIs
run: make pigeon
working-directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
working-directory: ./mobile/ios
run: |
pod install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
working-directory: ./mobile/ios
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
- name: Import Certificate
env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
working-directory: ./mobile/ios
run: |
# Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
- name: Create keychain and import certificate
env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
working-directory: ./mobile/ios
run: |
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Verify certificate was imported
security find-identity -v -p codesigning build.keychain
- name: Build and deploy to TestFlight
env:
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_NAME: build.keychain
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }}
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
working-directory: ./mobile/ios
run: |
# Only upload to TestFlight on main branch
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
if [[ "$ENVIRONMENT" == "development" ]]; then
bundle exec fastlane gha_testflight_dev
else
bundle exec fastlane gha_release_prod
fi
else
# Build only, no TestFlight upload for non-main branches
bundle exec fastlane gha_build_only
fi
- name: Clean up keychain
if: always()
run: |
security delete-keychain build.keychain || true
- name: Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa

52
.github/workflows/cache-cleanup.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Cache Cleanup
on:
pull_request:
types:
- closed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
cleanup:
name: Cleanup
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Cleanup
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
REF: ${{ github.ref }}
run: |
gh extension install actions/gh-actions-cache
REPO=${{ github.repository }}
echo "Fetching list of cache keys"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B ${REF} -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R "$REPO" -B "${REF}" --confirm
done
echo "Done"

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

@@ -0,0 +1,31 @@
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
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
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

80
.github/workflows/check-pr-template.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Check PR Template
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]
permissions: {}
jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

126
.github/workflows/cli.yml vendored Normal file
View File

@@ -0,0 +1,126 @@
name: CLI Build
on:
push:
branches: [main]
paths:
- 'cli/**'
- '.github/workflows/cli.yml'
pull_request:
paths:
- 'cli/**'
- '.github/workflows/cli.yml'
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
publish:
name: CLI Publish
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
packages: write
defaults:
run:
working-directory: ./cli
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm publish --provenance --no-git-checks
if: ${{ github.event_name == 'release' }}
docker:
name: Docker
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: publish
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get package version
id: package-version
run: |
version=$(jq -r '.version' cli/package.json)
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
flavor: |
latest=false
images: |
name=ghcr.io/${{ github.repository_owner }}/immich-cli
tags: |
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'release' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}

107
.github/workflows/close-duplicates.yml vendored Normal file
View File

@@ -0,0 +1,107 @@
on:
issues:
types: [opened]
discussion:
types: [created]
name: Close likely duplicates
permissions: {}
jobs:
should_run:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.should_run.outputs.run }}
steps:
- id: should_run
run: echo "run=${{ github.event_name == 'issues' || github.event.discussion.category.name == 'Feature Request' }}" >> $GITHUB_OUTPUT
get_body:
runs-on: ubuntu-latest
needs: should_run
if: ${{ needs.should_run.outputs.should_run == 'true' }}
env:
EVENT: ${{ toJSON(github.event) }}
outputs:
body: ${{ steps.get_body.outputs.body }}
steps:
- id: get_body
run: |
BODY=$(echo """$EVENT""" | jq -r '.issue // .discussion | .body' | base64 -w 0)
echo "body=$BODY" >> $GITHUB_OUTPUT
get_checkbox_json:
runs-on: ubuntu-latest
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
- id: get_checkbox
env:
BODY: ${{ needs.get_body.outputs.body }}
run: |
CHECKED=$(echo "$BODY" | base64 -d | /mdq --output json '# I have searched | - [?] Yes' | jq '.items[0].list[0].checked // false')
echo "checked=$CHECKED" >> $GITHUB_OUTPUT
close_and_comment:
runs-on: ubuntu-latest
needs: [get_checkbox_json, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' && needs.get_checkbox_json.outputs.checked != 'true' }}
permissions:
issues: write
discussions: write
steps:
- name: Close issue
if: ${{ github.event_name == 'issues' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.issue.node_id }}
run: |
gh api graphql \
-f issueId="$NODE_ID" \
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
-f query='
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
addComment(input: {
subjectId: $issueId,
body: $body
}) {
__typename
}
closeIssue(input: {
issueId: $issueId,
stateReason: DUPLICATE
}) {
__typename
}
}'
- name: Close discussion
if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.discussion.node_id }}
run: |
gh api graphql \
-f discussionId="$NODE_ID" \
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
-f query='
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {
discussionId: $discussionId,
body: $body
}) {
__typename
}
closeDiscussion(input: {
discussionId: $discussionId,
reason: DUPLICATE
}) {
__typename
}
}'

38
.github/workflows/close-llm-pr.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Close LLM-generated PRs
on:
pull_request_target:
types: [labeled]
permissions: {}
jobs:
comment_and_close:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'llm-generated' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

88
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: 'CodeQL'
on:
push:
branches: ['main']
pull_request:
# The branches below must be a subset of the branches above
branches: ['main']
schedule:
- cron: '20 13 * * 1'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ['javascript', 'python']
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- id: token
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@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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# 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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
# 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
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
category: '/language:${{matrix.language}}'

194
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,194 @@
name: Docker
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
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@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
server:
- 'server/**'
- 'openapi/**'
- 'web/**'
- 'i18n/**'
machine-learning:
- 'machine-learning/**'
force-filters: |
- '.github/workflows/docker.yml'
- '.github/workflows/multi-runner-build.yml'
- '.github/actions/image-build'
force-events: 'workflow_dispatch,release'
retag_ml:
name: Re-Tag ML
needs: pre-job
permissions:
contents: read
packages: write
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag image
env:
REGISTRY_NAME: 'ghcr.io'
REPOSITORY: ${{ github.repository_owner }}/immich-machine-learning
TAG_OLD: main${{ matrix.suffix }}
TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }}
run: |
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
retag_server:
name: Re-Tag Server
needs: pre-job
permissions:
contents: read
packages: write
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag image
env:
REGISTRY_NAME: 'ghcr.io'
REPOSITORY: ${{ github.repository_owner }}/immich-server
TAG_OLD: main${{ matrix.suffix }}
TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }}
TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }}
run: |
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
machine-learning:
name: Build and Push ML
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
strategy:
fail-fast: false
matrix:
include:
- device: cpu
- device: cuda
suffixes: '-cuda'
platforms: linux/amd64
- device: openvino
suffixes: '-openvino'
platforms: linux/amd64
- device: armnn
suffixes: '-armnn'
platforms: linux/arm64
- device: rknn
suffixes: '-rknn'
platforms: linux/arm64
- device: rocm
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read
actions: read
packages: write
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
with:
image: immich-machine-learning
context: machine-learning
dockerfile: machine-learning/Dockerfile
platforms: ${{ matrix.platforms }}
runner-mapping: ${{ matrix.runner-mapping }}
suffixes: ${{ matrix.suffixes }}
dockerhub-push: ${{ github.event_name == 'release' }}
build-args: |
DEVICE=${{ matrix.device }}
server:
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@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read
actions: read
packages: write
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
with:
image: immich-server
context: .
dockerfile: server/Dockerfile
dockerhub-push: ${{ github.event_name == 'release' }}
build-args: |
DEVICE=cpu
success-check-server:
name: Docker Build & Push Server Success
needs: [server, retag_server]
permissions: {}
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}
success-check-ml:
name: Docker Build & Push ML Success
needs: [machine-learning, retag_ml]
permissions: {}
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}

94
.github/workflows/docs-build.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: Docs build
on:
push:
branches: [main]
pull_request:
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
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@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
docs:
- 'docs/**'
open-api:
- 'open-api/immich-openapi-specs.json'
force-filters: |
- '.github/workflows/docs-build.yml'
force-events: 'release'
force-branches: 'main'
build:
name: Docs Build
needs: pre-job
permissions:
contents: read
if: ${{ fromJSON(needs.pre-job.outputs.should_run).docs == true }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run install
run: pnpm install
- name: Check formatting
run: pnpm format
- name: Run build
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: docs-build-output
path: docs/build/
include-hidden-files: true
retention-days: 1

222
.github/workflows/docs-deploy.yml vendored Normal file
View File

@@ -0,0 +1,222 @@
name: Docs deploy
on:
workflow_run: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
workflows: ['Docs build']
types:
- completed
env:
TG_NON_INTERACTIVE: 'true'
jobs:
checks:
name: Docs Deploy Checks
runs-on: ubuntu-latest
permissions:
actions: read
pull-requests: read
outputs:
parameters: ${{ steps.parameters.outputs.result }}
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
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 }}
- if: ${{ github.event.workflow_run.conclusion != 'success' }}
run: echo 'The triggering workflow did not succeed' && exit 1
- name: Get artifact
id: get-artifact
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "docs-build-output"
})[0];
if (!matchArtifact) {
console.log("No artifact found with the name docs-build-output, build job was skipped")
return { found: false };
}
return { found: true, id: matchArtifact.id };
- name: Determine deploy parameters
id: parameters
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const eventType = context.payload.workflow_run.event;
const isFork = context.payload.workflow_run.repository.fork;
let parameters;
console.log({eventType, isFork});
if (eventType == "push") {
const branch = context.payload.workflow_run.head_branch;
console.log({branch});
const shouldDeploy = !isFork && branch == "main";
parameters = {
event: "branch",
name: "main",
shouldDeploy
};
} else if (eventType == "pull_request") {
let pull_number = context.payload.workflow_run.pull_requests[0]?.number;
if(!pull_number) {
const {HEAD_SHA} = process.env;
const response = await github.rest.search.issuesAndPullRequests({q: `repo:${{ github.repository }} is:pr sha:${HEAD_SHA}`,per_page: 1,})
const items = response.data.items
if (items.length < 1) {
throw new Error("No pull request found for the commit")
}
const pullRequestNumber = items[0].number
console.info("Pull request number is", pullRequestNumber)
pull_number = pullRequestNumber
}
const {data: pr} = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number
});
console.log({pull_number});
parameters = {
event: "pr",
name: `pr-${pull_number}`,
pr_number: pull_number,
shouldDeploy: true
};
} else if (eventType == "release") {
parameters = {
event: "release",
name: context.payload.workflow_run.head_branch,
shouldDeploy: !isFork
};
}
console.log(parameters);
return parameters;
deploy:
name: Docs Deploy
runs-on: ubuntu-latest
needs: checks
permissions:
contents: read
actions: read
pull-requests: write
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- name: Load parameters
id: parameters
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
PARAM_JSON: ${{ needs.checks.outputs.parameters }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const parameters = JSON.parse(process.env.PARAM_JSON);
core.setOutput("event", parameters.event);
core.setOutput("name", parameters.name);
core.setOutput("shouldDeploy", parameters.shouldDeploy);
- name: Download artifact
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
let artifact = JSON.parse(process.env.ARTIFACT_JSON);
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/docs-build-output.zip`, Buffer.from(download.data));
- name: Unzip artifact
run: unzip "${{ github.workspace }}/docs-build-output.zip" -d "${{ github.workspace }}/docs/build"
- name: Deploy Docs Subdomain
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
TF_VAR_prefix_event_type: ${{ steps.parameters.outputs.event }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run //deployment:tf apply'
- name: Deploy Docs Subdomain Output
id: docs-output
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
TF_VAR_prefix_event_type: ${{ steps.parameters.outputs.event }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: |
mise run //deployment:tf output -- -json | jq -r '
"projectName=\(.pages_project_name.value)",
"subdomain=\(.immich_app_branch_subdomain.value)"
' >> $GITHUB_OUTPUT
- name: Publish to Cloudflare Pages
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' }}
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs-release'
run: 'mise run //deployment:tf apply'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
if: ${{ steps.parameters.outputs.event == 'pr' }}
with:
token: ${{ steps.token.outputs.token }}
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
body: |
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
emojis: 'rocket'
body-include: '<!-- Docs PR URL -->'

50
.github/workflows/docs-destroy.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Docs destroy
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
types: [closed]
permissions: {}
env:
TG_NON_INTERACTIVE: 'true'
jobs:
deploy:
name: Docs Destroy
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- name: Destroy Docs Subdomain
env:
TF_VAR_prefix_name: 'pr-${{ github.event.number }}'
TF_VAR_prefix_event_type: 'pr'
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ steps.token.outputs.token }}
number: ${{ github.event.number }}
delete: true
body-include: '<!-- Docs PR URL -->'

61
.github/workflows/fix-format.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Fix formatting
on:
pull_request:
types: [labeled]
permissions: {}
jobs:
fix-formatting:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'fix:formatting' }}
permissions:
contents: write
pull-requests: write
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Fix formatting
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
- name: Commit and push
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
default_author: github_actions
message: 'chore: fix formatting'
- name: Remove label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
if: always()
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.issues.removeLabel({
issue_number: context.payload.pull_request.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: 'fix:formatting'
})

128
.github/workflows/merge-translations.yml vendored Normal file
View File

@@ -0,0 +1,128 @@
name: Merge translations
on:
workflow_dispatch:
workflow_call:
secrets:
PUSH_O_MATIC_APP_ID:
required: true
PUSH_O_MATIC_APP_KEY:
required: true
WEBLATE_TOKEN:
required: true
inputs:
skip:
description: 'Skip translations'
required: false
type: boolean
permissions: {}
env:
WEBLATE_HOST: 'https://hosted.weblate.org'
WEBLATE_COMPONENT: 'immich/immich'
jobs:
merge:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Find translation PR
id: find_pr
if: ${{ inputs.skip != true }}
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
set -euo pipefail
PR=$(gh pr list --repo $GITHUB_REPOSITORY --author weblate --json number,mergeable)
echo "$PR"
PR_NUMBER=$(echo "$PR" | jq '
if length == 1 then
.[0].number
else
error("Expected exactly 1 entry, got \(length)")
end
' 2>&1) || exit 1
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "Selected PR $PR_NUMBER"
if ! echo "$PR" | jq -e '.[0].mergeable == "MERGEABLE"'; then
echo "PR is not mergeable"
exit 1
fi
- name: Lock weblate
if: ${{ inputs.skip != true }}
env:
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
run: |
curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=true
- name: Commit translations
if: ${{ inputs.skip != true }}
env:
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
run: |
curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=commit
curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/repository/" -d operation=push
- name: Merge PR
id: merge_pr
if: ${{ inputs.skip != true }}
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }}
run: |
set -euo pipefail
REVIEW_ID=$(gh api -X POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" --field event='APPROVE' --field body='Automatically merging translations PR' \
| jq '.id')
echo "REVIEW_ID=$REVIEW_ID" >> $GITHUB_OUTPUT
gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash
- name: Wait for PR to merge
if: ${{ inputs.skip != true }}
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
PR_NUMBER: ${{ steps.find_pr.outputs.PR_NUMBER }}
REVIEW_ID: ${{ steps.merge_pr.outputs.REVIEW_ID }}
run: |
# So we clean up no matter what
set +e
for i in {1..100}; do
if gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state | jq -e '.state == "MERGED"'; then
echo "PR merged"
exit 0
else
echo "PR not merged yet, waiting..."
sleep 6
fi
done
echo "PR did not merge in time"
gh api -X PUT "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews/$REVIEW_ID/dismissals" --field message='Merge attempt timed out' --field event='DISMISS'
gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --disable-auto
exit 1
- name: Unlock weblate
if: ${{ inputs.skip != true }}
env:
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
run: |
curl --fail-with-body -X POST -H "Authorization: Token $WEBLATE_TOKEN" "$WEBLATE_HOST/api/components/$WEBLATE_COMPONENT/lock/" -d lock=false
- name: Report success
run: |
echo "Workflow completed successfully (or was skipped)"

View File

@@ -0,0 +1,12 @@
name: PR Conventional Commit
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
validate-pr-title:
name: Validate PR Title (conventional commit)
uses: immich-app/devtools/.github/workflows/shared-pr-require-conventional-commit.yml@main
permissions:
pull-requests: write

15
.github/workflows/org-zizmor.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Zizmor
on:
pull_request:
push:
branches: [main]
jobs:
zizmor:
name: Zizmor
uses: immich-app/devtools/.github/workflows/shared-zizmor.yml@main
permissions:
actions: read
contents: read
security-events: write

View File

@@ -0,0 +1,31 @@
name: PR Label Validation
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
types: [opened, labeled, unlabeled, synchronize]
permissions: {}
jobs:
validate-release-label:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- id: token
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: Require PR to have a changelog label
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
with:
token: ${{ steps.token.outputs.token }}
mode: exactly
count: 1
use_regex: true
labels: 'changelog:.*'
add_comment: true
message: 'Label error. Requires {{errorString}} {{count}} of: {{ provided }}. Found: {{ applied }}. A maintainer will add the required label.'

22
.github/workflows/pr-labeler.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: 'Pull Request Labeler'
on:
- pull_request_target # zizmor: ignore[dangerous-triggers] no attacker inputs are used here
permissions: {}
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- id: token
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/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with:
repo-token: ${{ steps.token.outputs.token }}

158
.github/workflows/prepare-release.yml vendored Normal file
View File

@@ -0,0 +1,158 @@
name: Prepare new release
on:
workflow_dispatch:
inputs:
serverBump:
description: 'Bump server version'
required: true
default: 'false'
type: choice
options:
- 'false'
- major
- minor
- patch
mobileBump:
description: 'Bump mobile build number'
required: false
type: boolean
skipTranslations:
description: 'Skip translations'
required: false
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-root
cancel-in-progress: true
permissions: {}
jobs:
merge_translations:
uses: ./.github/workflows/merge-translations.yml
with:
skip: ${{ inputs.skipTranslations }}
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
bump_version:
runs-on: ubuntu-latest
needs: [merge_translations]
outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
version: ${{ steps.output.outputs.version }}
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
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@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Bump version
env:
SERVER_BUMP: ${{ inputs.serverBump }}
MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
- name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
default_author: github_actions
message: 'chore: version ${{ steps.output.outputs.version }}'
tag: ${{ steps.output.outputs.version }}
push: true
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: bump_version
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: ${{ needs.bump_version.outputs.ref }}
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: [build_mobile, bump_version]
permissions:
actions: read # To download the app artifact
# No content permissions are needed because it uses the app-token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true
body_path: misc/release/notes.tmpl
files: |
docker/docker-compose.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk

63
.github/workflows/preview-label.yaml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Preview label
on:
pull_request:
types: [labeled, closed]
permissions: {}
jobs:
comment-status:
runs-on: ubuntu-latest
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'preview' }}
permissions:
pull-requests: write
steps:
- id: token
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: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
remove-label:
runs-on: ubuntu-latest
if: ${{ (github.event.action == 'closed' || github.event.pull_request.head.repo.fork) && contains(github.event.pull_request.labels.*.name, 'preview') }}
permissions:
pull-requests: write
steps:
- id: token
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/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
github.rest.issues.removeLabel({
issue_number: context.payload.pull_request.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: 'preview'
})
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'Preview environment has been removed.'

47
.github/workflows/sdk.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Update Immich SDK
on:
release:
types: [published]
permissions: {}
jobs:
publish:
name: Publish `@immich/sdk`
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
packages: write
defaults:
run:
working-directory: ./open-api/typescript-sdk
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Publish
run: pnpm publish --provenance --no-git-checks

126
.github/workflows/static_analysis.yml vendored Normal file
View File

@@ -0,0 +1,126 @@
name: Static Code Analysis
on:
workflow_dispatch:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
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@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
mobile:
- 'mobile/**'
force-filters: |
- '.github/workflows/static_analysis.yml'
force-events: 'workflow_dispatch,release'
mobile-dart-analyze:
name: Run Dart Code Analysis
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./mobile
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
- 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:
github-token: ${{ steps.token.outputs.token }}
version: auto
working-directory: ./mobile
- name: Generate translation file
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
- name: Run Build Runner
run: make build
- name: Generate platform API
run: make pigeon
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
mobile/**/*.g.dart
mobile/**/*.gr.dart
mobile/**/*.drift.dart
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: |
echo "ERROR: Generated files not up to date! Run 'make build' and 'make pigeon' inside the mobile directory"
echo "Changed files: ${CHANGED_FILES}"
exit 1
- name: Run dart analyze
run: dart analyze --fatal-infos
- name: Run dart format
run: make format
# TODO: Re-enable after upgrading custom_lint
# - name: Run dart custom_lint
# run: dart run custom_lint
# TODO: Use https://github.com/CQLabs/dcm-action
- name: Run DCM
run: dcm analyze lib --fatal-style --fatal-warnings

903
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,903 @@
name: Test
on:
workflow_dispatch:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
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@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
i18n:
- 'i18n/**'
web:
- 'web/**'
- 'i18n/**'
- 'open-api/typescript-sdk/**'
server:
- 'server/**'
cli:
- 'cli/**'
- 'open-api/typescript-sdk/**'
e2e:
- 'e2e/**'
mobile:
- 'mobile/**'
machine-learning:
- 'machine-learning/**'
.github:
- '.github/**'
force-filters: |
- '.github/workflows/test.yml'
force-events: 'workflow_dispatch'
server-unit-tests:
name: Test & Lint Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./server
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run package manager install
run: pnpm install
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests:
name: Unit Test CLI
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./cli
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests-win:
name: Unit Test CLI (Windows)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: windows-latest
permissions:
contents: read
defaults:
run:
working-directory: ./cli
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install --frozen-lockfile
# Skip linter & formatter in Windows test.
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
web-lint:
name: Lint Web
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: mich
permissions:
contents: read
defaults:
run:
working-directory: ./web
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Run pnpm install
run: pnpm rebuild && pnpm install --frozen-lockfile
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run svelte checks
run: pnpm check:svelte
if: ${{ !cancelled() }}
web-unit-tests:
name: Test Web
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./web
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: pnpm install --frozen-lockfile
- name: Run tsc
run: pnpm check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
i18n-tests:
name: Test i18n
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies
run: pnpm --filter=immich-i18n install --frozen-lockfile
- name: Format
run: pnpm --filter=immich-i18n format:fix
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
i18n/**
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: |
echo "ERROR: i18n files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
exit 1
e2e-tests-lint:
name: End-to-End Lint
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./e2e
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
server-medium-tests:
name: Medium Tests (Server)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./server
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }}
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
working-directory: ./web
if: ${{ !cancelled() }}
- name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- 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
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
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:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web
if: ${{ !cancelled() }}
- name: Archive e2e test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run ui tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:ui
if: ${{ !cancelled() }}
- name: Archive ui test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run maintenance tests
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:maintenance
if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- 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-web-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
permissions: {}
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}
mobile-unit-tests:
name: Unit Test Mobile
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
- name: Generate translation file
run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart
working-directory: ./mobile
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1
ml-unit-tests:
name: Unit Test ML
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./machine-learning
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
python-version: 3.11
- name: Install dependencies
run: |
uv sync --extra cpu
- name: Lint with ruff
run: |
uv run ruff check --output-format=github immich_ml
- name: Format with ruff
run: |
uv run ruff format --check immich_ml
- name: Run mypy type checking
run: |
uv run mypy --strict immich_ml/
- name: Run tests and coverage
run: |
uv run pytest --cov=immich_ml --cov-report term-missing
github-files-formatting:
name: .github Files Formatting
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run)['.github'] == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./.github
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: pnpm install --frozen-lockfile
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- id: token
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
with:
ignore_paths: >-
**/open-api/** **/openapi** **/node_modules/**
generated-api-up-to-date:
name: OpenAPI Clients
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Build the app
run: pnpm --filter immich build
- name: Run API generation
run: ./bin/generate-open-api.sh
working-directory: open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
mobile/openapi
open-api/typescript-sdk
open-api/immich-openapi-specs.json
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: |
echo "ERROR: Generated files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
exit 1
sql-schema-up-to-date:
name: SQL Schema Checks
runs-on: ubuntu-latest
permissions:
contents: read
services:
postgres:
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:dbf18b3ffea4a81434c65b71e20d27203baf903a0275f4341e4c16dfd901fd67
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: immich
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
ports:
- 5432:5432
defaults:
run:
working-directory: ./server
steps:
- id: token
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@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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build the app
run: pnpm build
- name: Run existing migrations
run: pnpm migrations:run
- name: Test npm run schema:reset command works
run: pnpm schema:reset
- name: Generate new migrations
continue-on-error: true
run: pnpm migrations:generate src/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
server/src
- name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: |
echo "ERROR: Generated migration files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
cat ./src/*-TestMigration.ts
exit 1
- name: Run SQL generation
run: pnpm sync:sql
env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-sql-files
with:
files: |
server/src/queries
- name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
env:
CHANGED_FILES: ${{ steps.verify-changed-sql-files.outputs.changed_files }}
run: |
echo "ERROR: Generated SQL files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
git diff
exit 1
# mobile-integration-tests:
# name: Run mobile end-to-end integration tests
# runs-on: macos-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-java@v3
# with:
# distribution: 'zulu'
# java-version: '12.x'
# cache: 'gradle'
# - name: Cache android SDK
# uses: actions/cache@v3
# id: android-sdk
# with:
# key: android-sdk
# path: |
# /usr/local/lib/android/
# ~/.android
# - name: Cache Gradle
# uses: actions/cache@v3
# with:
# path: |
# ./mobile/build/
# ./mobile/android/.gradle/
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
# - name: Setup Android SDK
# if: steps.android-sdk.outputs.cache-hit != 'true'
# uses: android-actions/setup-android@v2
# - name: AVD cache
# uses: actions/cache@v3
# id: avd-cache
# with:
# path: |
# ~/.android/avd/*
# ~/.android/adb*
# key: avd-29
# - name: create AVD and generate snapshot for caching
# if: steps.avd-cache.outputs.cache-hit != 'true'
# uses: reactivecircus/android-emulator-runner@v2.27.0
# with:
# working-directory: ./mobile
# cores: 2
# api-level: 29
# arch: x86_64
# profile: pixel
# target: default
# force-avd-creation: false
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# disable-animations: false
# script: echo "Generated AVD snapshot for caching."
# - name: Setup Flutter SDK
# uses: subosito/flutter-action@v2
# with:
# channel: 'stable'
# flutter-version: '3.7.3'
# cache: true
# - name: Run integration tests
# uses: Wandalen/wretry.action@master
# with:
# action: reactivecircus/android-emulator-runner@v2.27.0
# with: |
# working-directory: ./mobile
# cores: 2
# api-level: 29
# arch: x86_64
# profile: pixel
# target: default
# force-avd-creation: false
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# disable-animations: true
# script: |
# flutter pub get
# flutter test integration_test
# attempt_limit: 3

73
.github/workflows/weblate-lock.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Weblate checks
on:
pull_request:
branches: [main]
types:
- opened
- synchronize
- ready_for_review
- auto_merge_enabled
- auto_merge_disabled
permissions: {}
env:
BOT_NAME: immich-push-o-matic
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
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@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
i18n:
- modified: 'i18n/!(en|package)**\.json'
skip-force-logic: 'true'
enforce-lock:
name: Check Weblate Lock
needs: [pre-job]
runs-on: ubuntu-latest
permissions: {}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
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: Bot review status
env:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.pull_request_review.pull_request.number }}
GH_TOKEN: ${{ steps.token.outputs.token }}
run: |
# Then check for APPROVED by the bot, if absent fail
gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json reviews | jq -e '.reviews | map(select(.author.login == env.BOT_NAME and .state == "APPROVED")) | length > 0' \
|| (echo "The push-o-matic bot has not approved this PR yet" && exit 1)
success-check-lock:
name: Weblate Lock Check Success
needs: [enforce-lock]
runs-on: ubuntu-latest
permissions: {}
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}

2
.gitignore vendored
View File

@@ -28,5 +28,3 @@ vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
.devcontainer/.env*
docker/dev-data

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "mobile/.isar"]
path = mobile/.isar
url = https://github.com/isar/isar
[submodule "e2e/test-assets"]
path = e2e/test-assets
url = https://github.com/immich-app/test-assets

24
.pnpmfile.cjs Normal file
View File

@@ -0,0 +1,24 @@
module.exports = {
hooks: {
readPackage: (pkg) => {
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") {
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

@@ -5,6 +5,13 @@
"dbaeumer.vscode-eslint",
"dart-code.flutter",
"dart-code.dart-code",
"dcmdev.dcm-vscode-extension"
"dcmdev.dcm-vscode-extension",
"bradlc.vscode-tailwindcss",
"ms-playwright.playwright",
"vitest.explorer",
"editorconfig.editorconfig",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"bluebrown.yamlfmt"
]
}

48
.vscode/settings.json vendored
View File

@@ -1,8 +1,7 @@
{
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code",
@@ -19,18 +18,15 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[svelte]": {
"editor.codeActionsOnSave": {
@@ -38,8 +34,7 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"[typescript]": {
"editor.codeActionsOnSave": {
@@ -47,18 +42,45 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
"editor.formatOnSave": true
},
"cSpell.words": ["immich"],
"css.lint.unknownAtRules": "ignore",
"editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true,
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [
{ "directory": "cli", "changeProcessCWD": true },
{ "directory": "e2e", "changeProcessCWD": true },
{ "directory": "server", "changeProcessCWD": true },
{ "directory": "web", "changeProcessCWD": true }
],
"files.watcherExclude": {
"**/.jj/**": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/.svelte-kit/**": true
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"search.exclude": {
"**/node_modules": true,
"**/build": true,
"**/dist": true,
"**/.svelte-kit": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
"tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**"
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
}

7
CODEOWNERS Normal file
View File

@@ -0,0 +1,7 @@
/.github/ @bo0tzz
/docker/ @bo0tzz
/server/ @danieldietzler
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen

134
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,134 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for
our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our
mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or
political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in
a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our
standards of acceptable behavior and will take appropriate and fair
corrective action in response to any behavior that they deem
inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct, and will
communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also
applies when an individual is officially representing the community in
public spaces. Examples of representing our community include using an
official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline
event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the community leaders responsible for enforcement
at our Discord channel. All complaints
will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and
security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in
determining the consequences for any action they deem in violation of
this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior
deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders,
providing clarity around the nature of the violation and an
explanation of why the behavior was inappropriate. A public apology
may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued
behavior. No interaction with the people involved, including
unsolicited interaction with those enforcing the Code of Conduct, for
a specified period of time. This includes avoiding interactions in
community spaces as well as external channels like social
media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards,
including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or
public communication with the community for a specified period of
time. No public or private interaction with the people involved,
including unsolicited interaction with those enforcing the Code of
Conduct, is allowed during this period. Violating these terms may lead
to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of
community standards, including sustained inappropriate behavior,
harassment of an individual, or aggression toward or disparagement of
classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor
Covenant][homepage], version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of
conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the
FAQ at https://www.contributor-covenant.org/faq. Translations are
available at https://www.contributor-covenant.org/translations.

45
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,45 @@
# Contributing to Immich
We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product!
## Getting started
To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers).
There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture).
## General
Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR.
## Finding work
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
## Use of generative AI
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
## Feature freezes
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
- Sharing/Asset ownership
- (External) libraries
## Non-code contributions
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team.
### Translations
All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated!
### Datasets
Help us improve our [Immich Datasets](https://datasets.immich.app) by submitting photos and videos taken from a variety of devices, including smartphones, DSLRs, and action cameras, as well as photos with unique features, such as panoramas, burst photos, and photo spheres. These datasets will be publically available for anyone to use, do not submit private/sensitive photos.
### Community support
If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.

178
Makefile
View File

@@ -1,68 +1,152 @@
DOCKER_COMPOSE_FILE := docker/docker-compose.yml
.PHONY: dev dev-down open-api open-api-dart open-api-typescript \
build-server build-web test-server test-medium test-e2e \
lint-server lint-web format
# Full development environment
dev:
@echo "Starting Docker services (Postgres 18 + Redis)..."
docker compose -f $(DOCKER_COMPOSE_FILE) up -d
@echo "Waiting for services to be healthy..."
@sleep 3
# @echo "Starting web dev server (background)..."
# pnpm --filter web dev &
@echo "Starting NestJS server with hot reload..."
DB_HOSTNAME=localhost DB_PORT=5435 DB_DATABASE_NAME=app REDIS_HOSTNAME=localhost pnpm --filter immich start:dev
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-down:
docker compose -f $(DOCKER_COMPOSE_FILE) down --remove-orphans
@-pkill -f "nest start" 2>/dev/null || true
@-pkill -f "vite dev" 2>/dev/null || true
@echo "All services stopped."
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
# OpenAPI SDK generation
dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
dev-docs:
npm --prefix docs run start
.PHONY: e2e
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api
open-api:
cd open-api && ./bin/generate-open-api.sh
cd ./open-api && bash ./bin/generate-open-api.sh
open-api-dart:
cd open-api && npx --yes @openapitools/openapi-generator-cli generate -g dart -i ../server/server-openapi-specs.json -o ../mobile/openapi
cd ./open-api && bash ./bin/generate-open-api.sh dart
open-api-typescript:
cd open-api && npx --yes oazapfts ../server/server-openapi-specs.json --optimistic > typescript-sdk/src/fetch-client.ts
cd ./open-api && bash ./bin/generate-open-api.sh typescript
# Build targets
build-server:
pnpm --filter immich build
sql:
pnpm --filter immich run sync:sql
build-web:
pnpm --filter web build
attach-server:
docker exec -it docker_immich-server_1 sh
# Test targets
test-server:
pnpm --filter immich test
renovate:
LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset
test-medium:
pnpm --filter immich test:medium
# Directories that need to be created for volumes or build output
VOLUME_DIRS = \
./.pnpm-store \
./web/.svelte-kit \
./web/node_modules \
./web/coverage \
./e2e/node_modules \
./docs/node_modules \
./server/node_modules \
./open-api/typescript-sdk/node_modules \
./.github/node_modules \
./node_modules \
./cli/node_modules
# Include .env file if it exists
-include docker/.env
MODULES = e2e server web cli sdk docs .github
# directory to package name mapping function
# cli = @immich/cli
# docs = documentation
# e2e = immich-e2e
# open-api/typescript-sdk = @immich/sdk
# server = immich
# web = immich-web
map-package = $(subst sdk,@immich/sdk,$(subst cli,@immich/cli,$(subst docs,documentation,$(subst e2e,immich-e2e,$(subst server,immich,$(subst web,immich-web,$1))))))
audit-%:
pnpm --filter $(call map-package,$*) audit fix
install-%:
pnpm --filter $(call map-package,$*) install $(if $(FROZEN),--frozen-lockfile) $(if $(OFFLINE),--offline)
build-cli: build-sdk
build-web: build-sdk
build-%: install-%
pnpm --filter $(call map-package,$*) run build
format-%:
pnpm --filter $(call map-package,$*) run format:fix
lint-%:
pnpm --filter $(call map-package,$*) run lint:fix
check-%:
pnpm --filter $(call map-package,$*) run check
check-web:
pnpm --filter immich-web run check:typescript
pnpm --filter immich-web run check:svelte
test-%:
pnpm --filter $(call map-package,$*) run test
test-e2e:
pnpm --filter immich-e2e test
docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
test-medium:
docker run \
--rm \
-v ./server/src:/usr/src/app/src \
-v ./server/test:/usr/src/app/test \
-v ./server/vitest.config.medium.mjs:/usr/src/app/vitest.config.medium.mjs \
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
-e NODE_ENV=development \
immich-server:latest \
-c "pnpm test:medium -- --run"
test-medium-dev:
docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
# Lint & format
lint-server:
pnpm --filter immich lint
install-all:
pnpm -r --filter '!documentation' install
lint-web:
pnpm --filter web lint
build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ;
format:
pnpm format
check-all:
pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/"
lint-all:
pnpm -r --filter '!documentation' run lint:fix
format-all:
pnpm -r --filter '!documentation' run format:fix
audit-all:
pnpm -r --filter '!documentation' audit fix
hygiene-all: audit-all
pnpm -r --filter '!documentation' run "/(format:fix|check|check:svelte|check:typescript|sql)/"
# Install dependencies
install:
pnpm install
test-all:
pnpm -r --filter '!documentation' run "/^test/"
# Clean everything
clean:
docker compose -f $(DOCKER_COMPOSE_FILE) down -v --remove-orphans
rm -rf node_modules server/node_modules web/node_modules e2e/node_modules
find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
setup-server-dev: install-server
setup-web-dev: install-sdk build-sdk install-web

166
README.md
View File

@@ -1,55 +1,133 @@
# Core Monorepo
<p align="center">
<br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: AGPLv3"></a>
<a href="https://discord.immich.app">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
</p>
A full-stack monorepo template with **NestJS** (server), **SvelteKit** (web), and **Flutter** (mobile), powered by **pnpm workspaces**.
<p align="center">
<img src="design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
</p>
<h3 align="center">High performance self-hosted photo and video management solution</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
## Architecture
<p align="center">
<a href="readme_i18n/README_ca_ES.md">Català</a>
<a href="readme_i18n/README_es_ES.md">Español</a>
<a href="readme_i18n/README_fr_FR.md">Français</a>
<a href="readme_i18n/README_it_IT.md">Italiano</a>
<a href="readme_i18n/README_ja_JP.md">日本語</a>
<a href="readme_i18n/README_ko_KR.md">한국어</a>
<a href="readme_i18n/README_de_DE.md">Deutsch</a>
<a href="readme_i18n/README_nl_NL.md">Nederlands</a>
<a href="readme_i18n/README_tr_TR.md">Türkçe</a>
<a href="readme_i18n/README_zh_CN.md">简体中文</a>
<a href="readme_i18n/README_zh_TW.md">正體中文</a>
<a href="readme_i18n/README_uk_UA.md">Українська</a>
<a href="readme_i18n/README_ru_RU.md">Русский</a>
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
<a href="readme_i18n/README_ar_JO.md">العربية</a>
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
<a href="readme_i18n/README_th_TH.md">ภาษาไทย</a>
</p>
```
server/ NestJS REST API with PostgreSQL + Redis
web/ SvelteKit frontend (static adapter)
mobile/ Flutter mobile app
e2e/ End-to-end tests (Vitest + Playwright)
open-api/ OpenAPI spec generation & TypeScript SDK
i18n/ Internationalization strings
docker/ Docker Compose for local development
```
## Quick Start
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
```bash
# Install dependencies
pnpm install
> [!NOTE]
> You can find the main documentation, including installation guides, at https://immich.app/.
# Start Postgres + Redis
make dev
## Links
# Or individually:
make dev-down # Stop services
make build-server # Build NestJS server
make build-web # Build SvelteKit app
make open-api # Regenerate OpenAPI spec & SDK
```
- [Documentation](https://docs.immich.app/)
- [About](https://docs.immich.app/overview/introduction)
- [Installation](https://docs.immich.app/install/requirements)
- [Roadmap](https://immich.app/roadmap)
- [Demo](#demo)
- [Features](#features)
- [Translations](https://docs.immich.app/developer/translations)
- [Contributing](https://docs.immich.app/overview/support-the-project)
## Development
## Demo
| Command | Description |
|---------|-------------|
| `make dev` | Start Docker services + web + server |
| `make dev-down` | Stop all services |
| `make build-server` | Build the NestJS server |
| `make build-web` | Build the SvelteKit web app |
| `make open-api` | Regenerate OpenAPI spec & SDKs |
| `make test-server` | Run server unit tests |
| `make test-e2e` | Run end-to-end tests |
| `make lint-server` | Lint the server |
| `make lint-web` | Lint the web app |
| `make clean` | Remove containers, volumes, node_modules |
Access the demo [here](https://demo.immich.app). For the mobile app, you can use `https://demo.immich.app` for the `Server Endpoint URL`.
## Stack
### Login credentials
- **Server**: NestJS, Kysely (query builder), PostgreSQL 18, Redis
- **Web**: SvelteKit 2, Svelte 5, TailwindCSS, @immich/ui
- **Mobile**: Flutter / Dart
- **API**: Auto-generated OpenAPI spec with TypeScript SDK via oazapfts
- **Auth**: JWT (access + refresh tokens), API keys, session management
- **i18n**: svelte-i18n with English base locale
| Email | Password |
| --------------- | -------- |
| demo@immich.app | demo |
## Features
| Features | Mobile | Web |
| :------------------------------------------- | ------ | --- |
| Upload and view videos and photos | Yes | Yes |
| Auto backup when the app is opened | Yes | N/A |
| Prevent duplication of assets | Yes | Yes |
| Selective album(s) for backup | Yes | N/A |
| Download photos and videos to local device | Yes | Yes |
| Multi-user support | Yes | Yes |
| Album and Shared albums | Yes | Yes |
| Scrubbable/draggable scrollbar | Yes | Yes |
| Support raw formats | Yes | Yes |
| Metadata view (EXIF, map) | Yes | Yes |
| Search by metadata, objects, faces, and CLIP | Yes | Yes |
| Administrative functions (user management) | No | Yes |
| Background backup | Yes | N/A |
| Virtual scroll | Yes | Yes |
| OAuth support | Yes | Yes |
| API Keys | N/A | Yes |
| LivePhoto/MotionPhoto backup and playback | Yes | Yes |
| Support 360 degree image display | No | Yes |
| User-defined storage structure | Yes | Yes |
| Public Sharing | Yes | Yes |
| Archive and Favorites | Yes | Yes |
| Global Map | Yes | Yes |
| Partner Sharing | Yes | Yes |
| Facial recognition and clustering | Yes | Yes |
| Memories (x years ago) | Yes | Yes |
| Offline support | Yes | No |
| Read-only gallery | Yes | Yes |
| Stacked Photos | Yes | Yes |
| Tags | No | Yes |
| Folder View | Yes | Yes |
## Translations
Read more about translations [here](https://docs.immich.app/developer/translations).
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Translation status" />
</a>
## Repository activity
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## Star history
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
</picture>
</a>
## Contributors
<a href="https://github.com/immich-app/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `security@immich.app`

20
cli/.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
[*.{ts,js}]
quote_type = single
[*.{md,mdx}]
max_line_length = off
trim_trailing_whitespace = false
[*.{yml,yaml}]
quote_type = double

15
cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*-debug.log
*-error.log
/.nyc_output
/dist
/lib
/tmp
/yarn.lock
node_modules
oclif.manifest.json
.vscode
.idea
/coverage/
.reverse-geocoding-dump/
upload/

15
cli/.npmignore Normal file
View File

@@ -0,0 +1,15 @@
**/*.spec.js
coverage/**
src/**
upload/**
.editorconfig
.eslintignore
.eslintrc.cjs
.gitignore
.prettierignore
.prettierrc
Dockerfile
package-lock.json
tsconfig.json
vite.config.ts
vitest.config.ts

1
cli/.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.13.1

17
cli/.prettierignore Normal file
View File

@@ -0,0 +1,17 @@
.DS_Store
node_modules
/build
/package
.env
.env.*
!.env.example
*.md
*.json
coverage
dist
**/migrations/**
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

8
cli/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true,
"plugins": ["prettier-plugin-organize-imports"],
"organizeImportsSkipDestructiveCodeActions": true
}

14
cli/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./
COPY ./cli ./cli/
COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/
RUN corepack enable pnpm && \
pnpm install --filter @immich/sdk --filter @immich/cli --frozen-lockfile && \
pnpm --filter @immich/sdk build && \
pnpm --filter @immich/cli build
WORKDIR /import
ENTRYPOINT ["node", "/usr/src/app/cli/dist"]

620
cli/LICENSE Normal file
View File

@@ -0,0 +1,620 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS

38
cli/README.md Normal file
View File

@@ -0,0 +1,38 @@
A command-line interface for interfacing with the self-hosted photo manager [Immich](https://immich.app/).
Please see the [Immich CLI documentation](https://docs.immich.app/features/command-line-interface).
# For developers
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
$ pnpm install
$ pnpm run build
Then, to build the open-api client run the following in the open-api folder:
$ ./bin/generate-open-api.sh
## Run from build
Go to the cli folder and build it:
$ pnpm install
$ pnpm run build
$ node dist/index.js
## Run and Debug from source (VSCode)
With VScode you can run and debug the Immich CLI. Go to the launch.json file, find the Immich CLI config and change this with the command you need to debug
`"args": ["upload", "--help"],`
replace that for the command of your choice.
## Install from build
You can also build and install the CLI using
$ pnpm run build
$ pnpm install -g .
****

2
cli/bin/immich Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js';

51
cli/eslint.config.mjs Normal file
View File

@@ -0,0 +1,51 @@
import js from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import typescriptEslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default typescriptEslint.config([
eslintPluginUnicorn.configs.recommended,
eslintPluginPrettierRecommended,
js.configs.recommended,
typescriptEslint.configs.recommended,
{
ignores: ['eslint.config.mjs', 'dist'],
},
{
languageOptions: {
globals: {
...globals.node,
},
parser: typescriptEslint.parser,
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-process-exit': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
'object-shorthand': ['error', 'always'],
},
},
]);

29
cli/mise.toml Normal file
View File

@@ -0,0 +1,29 @@
[tasks.install]
run = "pnpm install --filter @immich/cli --frozen-lockfile"
[tasks.build]
env._.path = "./node_modules/.bin"
run = "vite build"
[tasks.test]
env._.path = "./node_modules/.bin"
run = "vite"
[tasks.lint]
env._.path = "./node_modules/.bin"
run = "eslint \"src/**/*.ts\" --max-warnings 0"
[tasks."lint-fix"]
run = { task = "lint --fix" }
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."
[tasks.check]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"

74
cli/package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "@immich/cli",
"version": "2.5.6",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
"bin": {
"immich": "./bin/immich"
},
"license": "GNU Affero General Public License version 3",
"keywords": [
"immich",
"cli"
],
"devDependencies": {
"@eslint/js": "^10.0.0",
"@immich/sdk": "workspace:*",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.11.0",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"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",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"
},
"scripts": {
"build": "vite build",
"build:dev": "vite build --sourcemap true",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"prepack": "pnpm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"check": "tsc --noEmit"
},
"repository": {
"type": "git",
"url": "git+https://github.com/immich-app/immich.git",
"directory": "cli"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"chokidar": "^4.0.3",
"fast-glob": "^3.3.2",
"fastq": "^1.17.1",
"lodash-es": "^4.17.21",
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.13.1"
}
}

View File

@@ -0,0 +1,410 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
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,
deleteFiles,
findSidecar,
getAlbumName,
startWatch,
uploadFiles,
UploadOptionsDto,
} from 'src/commands/asset';
vi.mock('@immich/sdk');
describe('getAlbumName', () => {
it('should return a non-undefined value', () => {
if (os.platform() === 'win32') {
// This is meaningless for Unix systems.
expect(getAlbumName(String.raw`D:\test\Filename.txt`, {} as UploadOptionsDto)).toBe('test');
}
expect(getAlbumName('D:/parentfolder/test/Filename.txt', {} as UploadOptionsDto)).toBe('test');
});
it('has higher priority to return `albumName` in `options`', () => {
expect(getAlbumName('/parentfolder/test/Filename.txt', { albumName: 'example' } as UploadOptionsDto)).toBe(
'example',
);
});
});
describe('uploadFiles', () => {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
const testFilePath = path.join(testDir, 'test.png');
const testFileData = 'test';
const baseUrl = 'http://example.com';
const apiKey = 'key';
const retry = 3;
const fetchMocker = createFetchMock(vi);
beforeEach(() => {
// Create a test file
fs.writeFileSync(testFilePath, testFileData);
// Defaults
vi.mocked(defaults).baseUrl = baseUrl;
vi.mocked(defaults).headers = { 'x-api-key': apiKey };
fetchMocker.enableMocks();
fetchMocker.resetMocks();
});
it('returns new assets when upload file is successful', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
return {
status: 200,
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
};
});
await expect(uploadFiles([testFilePath], { concurrency: 1 })).resolves.toEqual([
{
filepath: testFilePath,
id: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
},
]);
});
it('returns new assets when upload file retry is successful', async () => {
let counter = 0;
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
counter++;
if (counter < retry) {
throw new Error('Network error');
}
return {
status: 200,
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
};
});
await expect(uploadFiles([testFilePath], { concurrency: 1 })).resolves.toEqual([
{
filepath: testFilePath,
id: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
},
]);
});
it('returns new assets when upload file retry is failed', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
throw new Error('Network error');
});
await expect(uploadFiles([testFilePath], { concurrency: 1 })).resolves.toEqual([]);
});
});
describe('checkForDuplicates', () => {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
const testFilePath = path.join(testDir, 'test.png');
const testFileData = 'test';
const testFileChecksum = 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3'; // SHA1
const retry = 3;
beforeEach(() => {
// Create a test file
fs.writeFileSync(testFilePath, testFileData);
});
it('checks duplicates', async () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: Action.Accept,
id: testFilePath,
},
],
});
await checkForDuplicates([testFilePath], { concurrency: 1 });
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: [
{
checksum: testFileChecksum,
id: testFilePath,
},
],
},
});
});
it('returns duplicates when check duplicates is rejected', async () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: Action.Reject,
id: testFilePath,
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
reason: Reason.Duplicate,
},
],
});
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
duplicates: [
{
filepath: testFilePath,
id: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
},
],
newFiles: [],
});
});
it('returns new assets when check duplicates is accepted', async () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: Action.Accept,
id: testFilePath,
},
],
});
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
duplicates: [],
newFiles: [testFilePath],
});
});
it('returns results when check duplicates retry is successful', async () => {
let mocked = vi.mocked(checkBulkUpload);
for (let i = 1; i < retry; i++) {
mocked = mocked.mockRejectedValueOnce(new Error('Network error'));
}
mocked.mockResolvedValue({
results: [
{
action: Action.Accept,
id: testFilePath,
},
],
});
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
duplicates: [],
newFiles: [testFilePath],
});
});
it('returns results when check duplicates retry is failed', async () => {
vi.mocked(checkBulkUpload).mockRejectedValue(new Error('Network error'));
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
duplicates: [],
newFiles: [],
});
});
});
describe('startWatch', () => {
let testFolder: string;
let checkBulkUploadMocked: MockedFunction<typeof checkBulkUpload>;
beforeEach(async () => {
vi.restoreAllMocks();
vi.mocked(getSupportedMediaTypes).mockResolvedValue({
image: ['.jpg'],
sidecar: ['.xmp'],
video: ['.mp4'],
});
testFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-startWatch-'));
checkBulkUploadMocked = vi.mocked(checkBulkUpload);
checkBulkUploadMocked.mockResolvedValue({
results: [],
});
});
it('should start watching a directory and upload new files', async () => {
const testFilePath = path.join(testFolder, 'test.jpg');
await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 });
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: [
expect.objectContaining({
id: testFilePath,
}),
],
},
}),
{ timeout: 5000 },
);
});
it('should filter out unsupported files', async () => {
const testFilePath = path.join(testFolder, 'test.jpg');
const unsupportedFilePath = path.join(testFolder, 'test.txt');
await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 });
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
}),
{ timeout: 5000 },
);
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: unsupportedFilePath,
}),
]),
},
});
});
it('should filter out ignored patterns', async () => {
const testFilePath = path.join(testFolder, 'test.jpg');
const ignoredPattern = 'ignored';
const ignoredFolder = path.join(testFolder, ignoredPattern);
await fs.promises.mkdir(ignoredFolder, { recursive: true });
const ignoredFilePath = path.join(ignoredFolder, 'ignored.jpg');
await startWatch([testFolder], { concurrency: 1, ignore: ignoredPattern }, { batchSize: 1, debounceTimeMs: 10 });
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
}),
{ timeout: 5000 },
);
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: ignoredFilePath,
}),
]),
},
});
});
afterEach(async () => {
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);
});
});

589
cli/src/commands/asset.ts Normal file
View File

@@ -0,0 +1,589 @@
import {
Action,
AssetBulkUploadCheckItem,
AssetBulkUploadCheckResult,
AssetMediaResponseDto,
AssetMediaStatus,
Permission,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
defaults,
getAllAlbums,
getSupportedMediaTypes,
} from '@immich/sdk';
import byteSize from 'byte-size';
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, existsSync } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import path, { basename } from 'node:path';
import { Queue } from 'src/queue';
import { BaseOptions, Batcher, authenticate, crawl, requirePermissions, s, sha1 } from 'src/utils';
const UPLOAD_WATCH_BATCH_SIZE = 100;
const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000;
// TODO figure out why `id` is missing
type AssetBulkUploadCheckResults = Array<AssetBulkUploadCheckResult & { id: string }>;
type Asset = { id: string; filepath: string };
export interface UploadOptionsDto {
recursive?: boolean;
ignore?: string;
dryRun?: boolean;
skipHash?: boolean;
delete?: boolean;
deleteDuplicates?: boolean;
album?: boolean;
albumName?: string;
includeHidden?: boolean;
concurrency: number;
progress?: boolean;
watch?: boolean;
jsonOutput?: boolean;
}
class UploadFile extends File {
constructor(
private filepath: string,
private _size: number,
) {
super([], basename(filepath));
}
get size() {
return this._size;
}
stream() {
return createReadStream(this.filepath) as any;
}
}
const uploadBatch = async (files: string[], options: UploadOptionsDto) => {
const { newFiles, duplicates } = await checkForDuplicates(files, options);
const newAssets = await uploadFiles(newFiles, options);
if (options.jsonOutput) {
console.log(JSON.stringify({ newFiles, duplicates, newAssets }, undefined, 4));
}
await updateAlbums([...newAssets, ...duplicates], options);
await deleteFiles(newAssets, duplicates, options);
};
export const startWatch = async (
paths: string[],
options: UploadOptionsDto,
{
batchSize = UPLOAD_WATCH_BATCH_SIZE,
debounceTimeMs = UPLOAD_WATCH_DEBOUNCE_TIME_MS,
}: { batchSize?: number; debounceTimeMs?: number } = {},
) => {
const watcherIgnored: Matcher[] = [];
const { image, video } = await getSupportedMediaTypes();
const extensions = new Set([...image, ...video]);
if (options.ignore) {
watcherIgnored.push((path) => micromatch.contains(path, `**/${options.ignore}`));
}
const pathsBatcher = new Batcher<string>({
batchSize,
debounceTimeMs,
onBatch: async (paths: string[]) => {
const uniquePaths = [...new Set(paths)];
await uploadBatch(uniquePaths, options);
},
});
const onFile = async (path: string, stats?: Stats) => {
if (stats?.isDirectory()) {
return;
}
const ext = '.' + path.split('.').pop()?.toLowerCase();
if (!ext || !extensions.has(ext)) {
return;
}
if (!options.progress) {
// logging when progress is disabled as it can cause issues with the progress bar rendering
console.log(`Change detected: ${path}`);
}
pathsBatcher.add(path);
};
const fsWatcher = watchFs(paths, {
ignoreInitial: true,
ignored: watcherIgnored,
alwaysStat: true,
awaitWriteFinish: true,
depth: options.recursive ? undefined : 1,
persistent: true,
})
.on('add', onFile)
.on('change', onFile)
.on('error', (error) => console.error(`Watcher error: ${error}`));
process.on('SIGINT', async () => {
console.log('Exiting...');
await fsWatcher.close();
process.exit();
});
};
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
await authenticate(baseOptions);
await requirePermissions([Permission.AssetUpload]);
const scanFiles = await scan(paths, options);
if (scanFiles.length === 0) {
if (options.watch) {
console.log('No files found initially.');
} else {
console.log('No files found, exiting');
return;
}
}
if (options.watch) {
console.log('Watching for changes...');
await startWatch(paths, options);
// watcher does not handle the initial scan
// as the scan() is a more efficient quick start with batched results
}
await uploadBatch(scanFiles, options);
};
const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
const { image, video } = await getSupportedMediaTypes();
console.log('Crawling for assets...');
const files = await crawl({
pathsToCrawl,
recursive: options.recursive,
exclusionPattern: options.ignore,
includeHidden: options.includeHidden,
extensions: [...image, ...video],
});
return files;
};
export const checkForDuplicates = async (files: string[], { concurrency, skipHash, progress }: UploadOptionsDto) => {
if (skipHash) {
console.log('Skipping hash check, assuming all files are new');
return { newFiles: files, duplicates: [] };
}
let multiBar: MultiBar | undefined;
let totalSize = 0;
const statsMap = new Map<string, Stats>();
// Calculate total size first
for (const filepath of files) {
const stats = await stat(filepath);
statsMap.set(filepath, stats);
totalSize += stats.size;
}
if (progress) {
multiBar = new MultiBar(
{
format: '{message} | {bar} | {percentage}% | ETA: {eta_formatted} | {value}/{total}',
formatValue: (v: number, options, type) => {
// Don't format percentage
if (type === 'percentage') {
return v.toString();
}
return byteSize(v).toString();
},
etaBuffer: 100, // Increase samples for ETA calculation
},
Presets.shades_classic,
);
// Ensure we restore cursor on interrupt
process.on('SIGINT', () => {
if (multiBar) {
multiBar.stop();
}
process.exit(0);
});
} else {
console.log(`Received ${files.length} files (${byteSize(totalSize)}), hashing...`);
}
const hashProgressBar = multiBar?.create(totalSize, 0, {
message: 'Hashing files ',
});
const checkProgressBar = multiBar?.create(totalSize, 0, {
message: 'Checking for duplicates',
});
const newFiles: string[] = [];
const duplicates: Asset[] = [];
const checkBulkUploadQueue = new Queue<AssetBulkUploadCheckItem[], void>(
async (assets: AssetBulkUploadCheckItem[]) => {
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
const results = response.results as AssetBulkUploadCheckResults;
for (const { id: filepath, assetId, action } of results) {
if (action === Action.Accept) {
newFiles.push(filepath);
} else {
// rejects are always duplicates
duplicates.push({ id: assetId as string, filepath });
}
}
// Update progress based on total size of processed files
let processedSize = 0;
for (const asset of assets) {
const stats = statsMap.get(asset.id);
processedSize += stats?.size || 0;
}
checkProgressBar?.increment(processedSize);
},
{ concurrency, retry: 3 },
);
const results: { id: string; checksum: string }[] = [];
let checkBulkUploadRequests: AssetBulkUploadCheckItem[] = [];
const queue = new Queue<string, AssetBulkUploadCheckItem[]>(
async (filepath: string): Promise<AssetBulkUploadCheckItem[]> => {
const stats = statsMap.get(filepath);
if (!stats) {
throw new Error(`Stats not found for ${filepath}`);
}
const dto = { id: filepath, checksum: await sha1(filepath) };
results.push(dto);
checkBulkUploadRequests.push(dto);
if (checkBulkUploadRequests.length === 5000) {
const batch = checkBulkUploadRequests;
checkBulkUploadRequests = [];
void checkBulkUploadQueue.push(batch);
}
hashProgressBar?.increment(stats.size);
return results;
},
{ concurrency, retry: 3 },
);
for (const item of files) {
void queue.push(item);
}
await queue.drained();
if (checkBulkUploadRequests.length > 0) {
void checkBulkUploadQueue.push(checkBulkUploadRequests);
}
await checkBulkUploadQueue.drained();
multiBar?.stop();
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
// Report failures
const failedTasks = queue.tasks.filter((task) => task.status === 'failed');
if (failedTasks.length > 0) {
console.log(`Failed to verify ${failedTasks.length} file${s(failedTasks.length)}:`);
for (const task of failedTasks) {
console.log(`- ${task.data} - ${task.error}`);
}
}
return { newFiles, duplicates };
};
export const uploadFiles = async (
files: string[],
{ dryRun, concurrency, progress }: UploadOptionsDto,
): Promise<Asset[]> => {
if (files.length === 0) {
console.log('All assets were already uploaded, nothing to do.');
return [];
}
// Compute total size first
let totalSize = 0;
const statsMap = new Map<string, Stats>();
for (const filepath of files) {
const stats = await stat(filepath);
statsMap.set(filepath, stats);
totalSize += stats.size;
}
if (dryRun) {
console.log(`Would have uploaded ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`);
return files.map((filepath) => ({ id: '', filepath }));
}
let uploadProgress: SingleBar | undefined;
if (progress) {
uploadProgress = new SingleBar(
{
format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}',
},
Presets.shades_classic,
);
} else {
console.log(`Uploading ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`);
}
uploadProgress?.start(totalSize, 0);
uploadProgress?.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
let duplicateCount = 0;
let duplicateSize = 0;
let successCount = 0;
let successSize = 0;
const newAssets: Asset[] = [];
const queue = new Queue<string, AssetMediaResponseDto>(
async (filepath: string) => {
const stats = statsMap.get(filepath);
if (!stats) {
throw new Error(`Stats not found for ${filepath}`);
}
const response = await uploadFile(filepath, stats);
newAssets.push({ id: response.id, filepath });
if (response.status === AssetMediaStatus.Duplicate) {
duplicateCount++;
duplicateSize += stats.size ?? 0;
} else {
successCount++;
successSize += stats.size ?? 0;
}
uploadProgress?.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) });
return response;
},
{ concurrency, retry: 3 },
);
for (const item of files) {
void queue.push(item);
}
await queue.drained();
uploadProgress?.stop();
console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`);
if (duplicateCount > 0) {
console.log(`Skipped ${duplicateCount} duplicate asset${s(duplicateCount)} (${byteSize(duplicateSize)})`);
}
// Report failures
const failedTasks = queue.tasks.filter((task) => task.status === 'failed');
if (failedTasks.length > 0) {
console.log(`Failed to upload ${failedTasks.length} asset${s(failedTasks.length)}:`);
for (const task of failedTasks) {
console.log(`- ${task.data} - ${task.error}`);
}
}
return newAssets;
};
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
const { baseUrl, headers } = defaults;
const formData = new FormData();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI');
formData.append('fileCreatedAt', stats.mtime.toISOString());
formData.append('fileModifiedAt', stats.mtime.toISOString());
formData.append('fileSize', String(stats.size));
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
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`, {
method: 'post',
redirect: 'error',
headers: headers as Record<string, string>,
body: formData,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(await response.text());
}
return response.json();
};
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;
}
if (options.deleteDuplicates) {
fileCount += duplicates.length;
}
if (options.dryRun) {
console.log(`Would have deleted ${fileCount} local asset${s(fileCount)}`);
return;
}
if (fileCount === 0) {
return;
}
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new SingleBar(
{ format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
deletionProgress.start(fileCount, 0);
const chunkDelete = async (files: Asset[]) => {
for (const assetBatch of chunk(files, options.concurrency)) {
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);
}
};
try {
if (options.delete) {
await chunkDelete(uploaded);
}
if (options.deleteDuplicates) {
await chunkDelete(duplicates);
}
} finally {
deletionProgress.stop();
}
};
const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
if (!options.album && !options.albumName) {
return;
}
const { dryRun, concurrency } = options;
const albums = await getAllAlbums({});
const existingAlbums = new Map(albums.map((album) => [album.albumName, album.id]));
const newAlbums: Set<string> = new Set();
for (const { filepath } of assets) {
const albumName = getAlbumName(filepath, options);
if (albumName && !existingAlbums.has(albumName)) {
newAlbums.add(albumName);
}
}
if (dryRun) {
// TODO print asset counts for new albums
console.log(`Would have created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Would have updated albums of ${assets.length} asset${s(assets.length)}`);
return;
}
const progressBar = new SingleBar(
{ format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums' },
Presets.shades_classic,
);
progressBar.start(newAlbums.size, 0);
try {
for (const albumNames of chunk([...newAlbums], concurrency)) {
const items = await Promise.all(
albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } })),
);
for (const { id, albumName } of items) {
existingAlbums.set(albumName, id);
}
progressBar.increment(albumNames.length);
}
} finally {
progressBar.stop();
}
console.log(`Successfully created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Successfully updated ${assets.length} asset${s(assets.length)}`);
const albumToAssets = new Map<string, string[]>();
for (const asset of assets) {
const albumName = getAlbumName(asset.filepath, options);
if (!albumName) {
continue;
}
const albumId = existingAlbums.get(albumName);
if (albumId) {
if (!albumToAssets.has(albumId)) {
albumToAssets.set(albumId, []);
}
albumToAssets.get(albumId)?.push(asset.id);
}
}
const albumUpdateProgress = new SingleBar(
{ format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
albumUpdateProgress.start(assets.length, 0);
try {
for (const [albumId, assets] of albumToAssets.entries()) {
for (const assetBatch of chunk(assets, Math.min(1000 * concurrency, 65_000))) {
await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
albumUpdateProgress.increment(assetBatch.length);
}
}
} finally {
albumUpdateProgress.stop();
}
};
// `filepath` valid format:
// - Windows: `D:\\test\\Filename.txt` or `D:/test/Filename.txt`
// - Unix: `/test/Filename.txt`
export const getAlbumName = (filepath: string, options: UploadOptionsDto) => {
return options.albumName ?? path.basename(path.dirname(filepath));
};

57
cli/src/commands/auth.ts Normal file
View File

@@ -0,0 +1,57 @@
import { getMyUser, Permission } from '@immich/sdk';
import { existsSync } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
import {
BaseOptions,
connect,
getAuthFilePath,
logError,
requirePermissions,
withError,
writeAuthFile,
} from 'src/utils';
export const login = async (url: string, key: string, options: BaseOptions) => {
console.log(`Logging in to ${url}`);
const { configDirectory: configDir } = options;
await connect(url, key);
await requirePermissions([Permission.UserRead]);
const [error, user] = await withError(getMyUser());
if (error) {
logError(error, 'Failed to load user info');
process.exit(1);
}
console.log(`Logged in as ${user.email}`);
if (!existsSync(configDir)) {
// Create config folder if it doesn't exist
const created = await mkdir(configDir, { recursive: true });
if (!created) {
console.log(`Failed to create config folder: ${configDir}`);
return;
}
}
await writeAuthFile(configDir, { url, key });
console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`);
};
export const logout = async (options: BaseOptions) => {
console.log('Logging out...');
const { configDirectory: configDir } = options;
const authFile = getAuthFilePath(configDir);
if (existsSync(authFile)) {
await unlink(authFile);
console.log(`Removed auth file: ${authFile}`);
}
console.log('Successfully logged out');
};

View File

@@ -0,0 +1,25 @@
import { getAssetStatistics, getMyUser, getServerVersion, getSupportedMediaTypes, Permission } from '@immich/sdk';
import { authenticate, BaseOptions, requirePermissions } from 'src/utils';
export const serverInfo = async (options: BaseOptions) => {
const { url } = await authenticate(options);
await requirePermissions([Permission.ServerAbout, Permission.AssetStatistics, Permission.UserRead]);
const [versionInfo, mediaTypes, stats, userInfo] = await Promise.all([
getServerVersion(),
getSupportedMediaTypes(),
getAssetStatistics({}),
getMyUser(),
]);
console.log(`Server Info (via ${userInfo.email})`);
console.log(` Url: ${url}`);
console.log(` Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(` Formats:`);
console.log(` Images: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(` Videos: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(` Statistics:`);
console.log(` Images: ${stats.images}`);
console.log(` Videos: ${stats.videos}`);
console.log(` Total: ${stats.total}`);
};

93
cli/src/index.ts Normal file
View File

@@ -0,0 +1,93 @@
#! /usr/bin/env node
import { Command, Option } from 'commander';
import os from 'node:os';
import path from 'node:path';
import { upload } from 'src/commands/asset';
import { login, logout } from 'src/commands/auth';
import { serverInfo } from 'src/commands/server-info';
import { version } from '../package.json';
const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
const defaultConcurrency = Math.max(1, os.cpus().length - 1);
const program = new Command()
.name('immich')
.version(version)
.description('Command line interface for Immich')
.addOption(
new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
.env('IMMICH_CONFIG_DIR')
.default(defaultConfigDirectory),
)
.addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL'))
.addOption(new Option('-k, --key [key]', 'Immich API key').env('IMMICH_API_KEY'));
program
.command('login')
.alias('login-key')
.description('Login using an API key')
.argument('url', 'Immich server URL')
.argument('key', 'Immich API key')
.action((url, key) => login(url, key, program.opts()));
program
.command('logout')
.description('Remove stored credentials')
.action(() => logout(program.opts()));
program
.command('server-info')
.description('Display server information')
.action(() => serverInfo(program.opts()));
program
.command('upload')
.description('Upload assets')
.usage('[paths...] [options]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
.addOption(
new Option('-a, --album', 'Automatically create albums based on folder name')
.env('IMMICH_AUTO_CREATE_ALBUM')
.default(false),
)
.addOption(
new Option('-A, --album-name <name>', 'Add all assets to specified album')
.env('IMMICH_ALBUM_NAME')
.conflicts('album'),
)
.addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN')
.default(false)
.conflicts('skipHash'),
)
.addOption(
new Option('-c, --concurrency <number>', 'Number of assets to upload at the same time')
.env('IMMICH_UPLOAD_CONCURRENCY')
.default(defaultConcurrency),
)
.addOption(
new Option('-j, --json-output', 'Output detailed information in json format')
.env('IMMICH_JSON_OUTPUT')
.default(false),
)
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.addOption(
new Option('--delete-duplicates', 'Delete local assets that are duplicates (already exist on server)').env(
'IMMICH_DELETE_DUPLICATES',
),
)
.addOption(new Option('--no-progress', 'Hide progress bars').env('IMMICH_PROGRESS_BAR').default(true))
.addOption(
new Option('--watch', 'Watch for changes and upload automatically')
.env('IMMICH_WATCH_CHANGES')
.default(false)
.implies({ progress: false }),
)
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.action((paths, options) => upload(paths, program.opts(), options));
program.parse(process.argv);

131
cli/src/queue.ts Normal file
View File

@@ -0,0 +1,131 @@
import * as fastq from 'fastq';
import { uniqueId } from 'lodash-es';
export type Task<T, R> = {
readonly id: string;
status: 'idle' | 'processing' | 'succeeded' | 'failed';
data: T;
error: unknown | undefined;
count: number;
// TODO: Could be useful to adding progress property.
// TODO: Could be useful to adding start_at/end_at/duration properties.
result: undefined | R;
};
export type QueueOptions = {
verbose?: boolean;
concurrency?: number;
retry?: number;
// TODO: Could be useful to adding timeout property for retry.
};
export type ComputedQueueOptions = Required<QueueOptions>;
export const defaultQueueOptions = {
concurrency: 1,
retry: 0,
verbose: false,
};
/**
* An in-memory queue that processes tasks in parallel with a given concurrency.
* @see {@link https://www.npmjs.com/package/fastq}
* @template T - The type of the worker task data.
* @template R - The type of the worker output data.
*/
export class Queue<T, R> {
private readonly queue: fastq.queueAsPromised<string, Task<T, R>>;
private readonly store = new Map<string, Task<T, R>>();
readonly options: ComputedQueueOptions;
readonly worker: (data: T) => Promise<R>;
/**
* Create a new queue.
* @param worker - The worker function that processes the task.
* @param options - The queue options.
*/
constructor(worker: (data: T) => Promise<R>, options?: QueueOptions) {
this.options = { ...defaultQueueOptions, ...options };
this.worker = worker;
this.store = new Map<string, Task<T, R>>();
this.queue = this.buildQueue();
}
get tasks(): Task<T, R>[] {
const tasks: Task<T, R>[] = [];
for (const task of this.store.values()) {
tasks.push(task);
}
return tasks;
}
getTask(id: string): Task<T, R> {
const task = this.store.get(id);
if (!task) {
throw new Error(`Task with id ${id} not found`);
}
return task;
}
/**
* Wait for the queue to be empty.
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
* This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
drained(): Promise<void> {
return this.queue.drained();
}
/**
* Add a task at the end of the queue.
* @see {@link https://www.npmjs.com/package/fastq}
* @param data
* @returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully).
* This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
async push(data: T): Promise<Task<T, R>> {
const id = uniqueId();
const task: Task<T, R> = { id, status: 'idle', error: undefined, count: 0, data, result: undefined };
this.store.set(id, task);
return this.queue.push(id);
}
// TODO: Support more function delegation to fastq.
private buildQueue(): fastq.queueAsPromised<string, Task<T, R>> {
return fastq.promise((id: string) => {
const task = this.getTask(id);
return this.work(task);
}, this.options.concurrency);
}
private async work(task: Task<T, R>): Promise<Task<T, R>> {
task.count += 1;
task.error = undefined;
task.status = 'processing';
if (this.options.verbose) {
console.log('[task] processing:', task);
}
try {
task.result = await this.worker(task.data);
task.status = 'succeeded';
if (this.options.verbose) {
console.log('[task] succeeded:', task);
}
return task;
} catch (error) {
task.error = error;
task.status = 'failed';
if (this.options.verbose) {
console.log('[task] failed:', task);
}
if (this.options.retry > 0 && task.count < this.options.retry) {
if (this.options.verbose) {
console.log('[task] retry:', task);
}
return this.work(task);
}
return task;
}
}
}

341
cli/src/utils.spec.ts Normal file
View File

@@ -0,0 +1,341 @@
import mockfs from 'mock-fs';
import { readFileSync } from 'node:fs';
import { Batcher, CrawlOptions, crawl } from 'src/utils';
import { Mock } from 'vitest';
interface Test {
test: string;
options: Omit<CrawlOptions, 'extensions'>;
files: Record<string, boolean>;
skipOnWin32?: boolean;
}
const cwd = process.cwd();
const readContent = (path: string) => {
return readFileSync(path).toString();
};
const extensions = [
'.jpg',
'.jpeg',
'.png',
'.heif',
'.heic',
'.tif',
'.nef',
'.webp',
'.tiff',
'.dng',
'.gif',
'.mov',
'.mp4',
'.webm',
];
const tests: Test[] = [
{
test: 'should return empty when crawling an empty path list',
options: {
pathsToCrawl: [],
},
files: {},
},
{
test: 'should crawl a single folder',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should crawl folders with quotes',
options: {
pathsToCrawl: ["/photo's/", '/photo"s/', '/photo`s/'],
},
files: {
"/photo's/image1.jpg": true,
'/photo"s/image2.jpg': true,
'/photo`s/image3.jpg': true,
},
skipOnWin32: true, // single quote interferes with mockfs root on Windows
},
{
test: 'should crawl a single file',
options: {
pathsToCrawl: ['/photos/image.jpg'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should crawl a single file and a folder',
options: {
pathsToCrawl: ['/photos/image.jpg', '/images/'],
},
files: {
'/photos/image.jpg': true,
'/images/image2.jpg': true,
},
},
{
test: 'should exclude by file extension',
options: {
pathsToCrawl: ['/photos/'],
exclusionPattern: '**/*.tif',
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by file extension without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
exclusionPattern: '**/*.TIF',
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by folder',
options: {
pathsToCrawl: ['/photos/'],
exclusionPattern: '**/raw/**',
recursive: true,
},
files: {
'/photos/image.jpg': true,
'/photos/raw/image.jpg': false,
'/photos/raw2/image.jpg': true,
'/photos/folder/raw/image.jpg': false,
'/photos/crawl/image.jpg': true,
},
},
{
test: 'should crawl multiple paths',
options: {
pathsToCrawl: ['/photos/', '/images/', '/albums/'],
},
files: {
'/photos/image1.jpg': true,
'/images/image2.jpg': true,
'/albums/image3.jpg': true,
},
},
{
test: 'should crawl a single path without trailing slash',
options: {
pathsToCrawl: ['/photos'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
recursive: true,
},
files: {
'/photos/image.jpg': true,
'/photos/subfolder/image1.jpg': true,
'/photos/subfolder/image2.jpg': true,
'/image1.jpg': false,
},
},
{
test: 'should filter file extensions',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.txt': false,
'/photos/1': false,
},
},
{
test: 'should include photo and video extensions',
options: {
pathsToCrawl: ['/photos/', '/videos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.jpeg': true,
'/photos/image.heic': true,
'/photos/image.heif': true,
'/photos/image.png': true,
'/photos/image.gif': true,
'/photos/image.tif': true,
'/photos/image.tiff': true,
'/photos/image.webp': true,
'/photos/image.dng': true,
'/photos/image.nef': true,
'/videos/video.mp4': true,
'/videos/video.mov': true,
'/videos/video.webm': true,
},
},
{
test: 'should check file extensions without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.Jpg': true,
'/photos/image.jpG': true,
'/photos/image.JPG': true,
'/photos/image.jpEg': true,
'/photos/image.TIFF': true,
'/photos/image.tif': true,
'/photos/image.dng': true,
'/photos/image.NEF': true,
},
},
{
test: 'should normalize the path',
options: {
pathsToCrawl: ['/photos/1/../2'],
},
files: {
'/photos/1/image.jpg': false,
'/photos/2/image.jpg': true,
},
},
{
test: 'should return absolute paths',
options: {
pathsToCrawl: ['photos'],
},
files: {
[`${cwd}/photos/1.jpg`]: true,
[`${cwd}/photos/2.jpg`]: true,
[`/photos/3.jpg`]: false,
},
},
{
test: 'should support ignoring full filename',
options: {
pathsToCrawl: ['/photos'],
exclusionPattern: '**/image2.jpg',
},
files: {
'/photos/image1.jpg': true,
'/photos/image2.jpg': false,
'/photos/image3.jpg': true,
},
},
{
test: 'should support ignoring file extensions',
options: {
pathsToCrawl: ['/photos'],
exclusionPattern: '**/*.png',
},
files: {
'/photos/image1.jpg': true,
'/photos/image2.png': false,
'/photos/image3.jpg': true,
},
},
{
test: 'should support ignoring folder names',
options: {
pathsToCrawl: ['/photos'],
recursive: true,
exclusionPattern: '**/raw/**',
},
files: {
'/photos/image1.jpg': true,
'/photos/image/image1.jpg': true,
'/photos/raw/image2.dng': false,
'/photos/raw/image3.dng': false,
'/photos/notraw/image3.jpg': true,
},
},
{
test: 'should support ignoring absolute paths',
options: {
// Currently, fast-glob has some caveat when dealing with `/`.
pathsToCrawl: ['/*s'],
recursive: true,
exclusionPattern: '/images/**',
},
files: {
'/photos/image1.jpg': true,
'/images/image2.jpg': false,
'/assets/image3.jpg': true,
},
},
];
describe('crawl', () => {
afterEach(() => {
mockfs.restore();
});
describe('crawl', () => {
for (const { test: name, options, files, skipOnWin32 } of tests) {
if (process.platform === 'win32' && skipOnWin32) {
test.skip(name);
continue;
}
it(name, async () => {
// The file contents is the same as the path.
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, file])));
const actual = await crawl({ ...options, extensions });
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => file);
// Compare file's content instead of path since a file can be represent in multiple ways.
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
});
}
});
});
describe('Batcher', () => {
let batcher: Batcher;
let onBatch: Mock;
beforeEach(() => {
onBatch = vi.fn();
batcher = new Batcher({ batchSize: 2, onBatch });
});
it('should trigger onBatch() when a batch limit is reached', async () => {
batcher.add('a');
batcher.add('b');
batcher.add('c');
expect(onBatch).toHaveBeenCalledOnce();
expect(onBatch).toHaveBeenCalledWith(['a', 'b']);
});
it('should trigger onBatch() when flush() is called', async () => {
batcher.add('a');
batcher.flush();
expect(onBatch).toHaveBeenCalledOnce();
expect(onBatch).toHaveBeenCalledWith(['a']);
});
it('should trigger onBatch() when debounce time reached', async () => {
vi.useFakeTimers();
batcher = new Batcher({ batchSize: 2, debounceTimeMs: 100, onBatch });
batcher.add('a');
expect(onBatch).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(onBatch).toHaveBeenCalledOnce();
expect(onBatch).toHaveBeenCalledWith(['a']);
vi.useRealTimers();
});
});

265
cli/src/utils.ts Normal file
View File

@@ -0,0 +1,265 @@
import { ApiKeyResponseDto, getMyApiKey, getMyUser, init, isHttpError, Permission } from '@immich/sdk';
import { convertPathToPattern, glob } from 'fast-glob';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { readFile, stat, writeFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import yaml from 'yaml';
export interface BaseOptions {
configDirectory: string;
key?: string;
url?: string;
}
export type AuthDto = { url: string; key: string };
type OldAuthDto = { instanceUrl: string; apiKey: string };
export const authenticate = async (options: BaseOptions): Promise<AuthDto> => {
const { configDirectory: configDir, url, key } = options;
// provided in command
if (url && key) {
return connect(url, key);
}
// fallback to auth file
const config = await readAuthFile(configDir);
const auth = await connect(config.url, config.key);
if (auth.url !== config.url) {
await writeAuthFile(configDir, auth);
}
return auth;
};
export const s = (count: number) => (count === 1 ? '' : 's');
let _apiKey: ApiKeyResponseDto;
export const requirePermissions = async (permissions: Permission[]) => {
if (!_apiKey) {
_apiKey = await getMyApiKey();
}
if (_apiKey.permissions.includes(Permission.All)) {
return;
}
const missing: Permission[] = [];
for (const permission of permissions) {
if (!_apiKey.permissions.includes(permission)) {
missing.push(permission);
}
}
if (missing.length > 0) {
const combined = missing.map((permission) => `"${permission}"`).join(', ');
console.log(
`Missing required permission${s(missing.length)}: ${combined}.
Please make sure your API key has the correct permissions.`,
);
process.exit(1);
}
};
export const connect = async (url: string, key: string) => {
const wellKnownUrl = new URL('.well-known/immich', url);
try {
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
const endpoint = new URL(wellKnown.api.endpoint, url).toString();
if (endpoint !== url) {
console.debug(`Discovered API at ${endpoint}`);
}
url = endpoint;
} catch {
// noop
}
init({ baseUrl: url, apiKey: key });
const [error] = await withError(getMyUser());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
process.exit(1);
}
return { url, key };
};
export const logError = (error: unknown, message: string) => {
if (isHttpError(error)) {
console.error(`${message}: ${error.status}`);
console.error(JSON.stringify(error.data, undefined, 2));
} else {
console.error(`${message} - ${error}`);
}
};
export const getAuthFilePath = (dir: string) => join(dir, 'auth.yml');
export const readAuthFile = async (dir: string) => {
try {
const data = await readFile(getAuthFilePath(dir));
// TODO add class-transform/validation
const auth = yaml.parse(data.toString()) as AuthDto | OldAuthDto;
const { instanceUrl, apiKey } = auth as OldAuthDto;
if (instanceUrl && apiKey) {
return { url: instanceUrl, key: apiKey };
}
return auth as AuthDto;
} catch (error: Error | any) {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
console.log('No auth file exists. Please login first.');
process.exit(1);
}
throw error;
}
};
export const writeAuthFile = async (dir: string, auth: AuthDto) =>
writeFile(getAuthFilePath(dir), yaml.stringify(auth), { mode: 0o600 });
export const withError = async <T>(promise: Promise<T>): Promise<[Error, undefined] | [undefined, T]> => {
try {
const result = await promise;
return [undefined, result];
} catch (error: Error | any) {
return [error, undefined];
}
};
export interface CrawlOptions {
pathsToCrawl: string[];
recursive?: boolean;
includeHidden?: boolean;
exclusionPattern?: string;
extensions: string[];
}
const convertPathToPatternOnWin = (path: string) => {
return platform() === 'win32' ? convertPathToPattern(path) : path;
};
export const crawl = async (options: CrawlOptions): Promise<string[]> => {
const { extensions: extensionsWithPeriod, recursive, pathsToCrawl, exclusionPattern, includeHidden } = options;
const extensions = extensionsWithPeriod.map((extension) => extension.replace('.', ''));
if (pathsToCrawl.length === 0) {
return [];
}
const patterns: string[] = [];
const crawledFiles: string[] = [];
for await (const currentPath of pathsToCrawl) {
try {
const absolutePath = resolve(currentPath);
const stats = await stat(absolutePath);
if (stats.isFile() || stats.isSymbolicLink()) {
crawledFiles.push(absolutePath);
} else {
patterns.push(convertPathToPatternOnWin(absolutePath));
}
} catch (error: any) {
if (error.code === 'ENOENT') {
patterns.push(convertPathToPatternOnWin(currentPath));
} else {
throw error;
}
}
}
if (patterns.length === 0) {
return crawledFiles;
}
const searchPatterns = patterns.map((pattern) => {
let escapedPattern = pattern.replaceAll("'", "[']").replaceAll('"', '["]').replaceAll('`', '[`]');
if (recursive) {
escapedPattern = escapedPattern + '/**';
}
return `${escapedPattern}/*.{${extensions.join(',')}}`;
});
const globbedFiles = await glob(searchPatterns, {
absolute: true,
caseSensitiveMatch: false,
dot: includeHidden,
ignore: [`**/${exclusionPattern}`],
});
globbedFiles.push(...crawledFiles);
return globbedFiles.toSorted();
};
export const sha1 = (filepath: string) => {
const hash = createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = createReadStream(filepath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
/**
* Batches items and calls onBatch to process them
* when the batch size is reached or the debounce time has passed.
*/
export class Batcher<T = unknown> {
private items: T[] = [];
private readonly batchSize: number;
private readonly debounceTimeMs?: number;
private readonly onBatch: (items: T[]) => void;
private debounceTimer?: NodeJS.Timeout;
constructor({
batchSize,
debounceTimeMs,
onBatch,
}: {
batchSize: number;
debounceTimeMs?: number;
onBatch: (items: T[]) => Promise<void>;
}) {
this.batchSize = batchSize;
this.debounceTimeMs = debounceTimeMs;
this.onBatch = onBatch;
}
private setDebounceTimer() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.debounceTimeMs) {
this.debounceTimer = setTimeout(() => this.flush(), this.debounceTimeMs);
}
}
private clearDebounceTimer() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = undefined;
}
}
add(item: T) {
this.items.push(item);
this.setDebounceTimer();
if (this.items.length >= this.batchSize) {
this.flush();
}
}
flush() {
this.clearDebounceTimer();
if (this.items.length === 0) {
return;
}
this.onBatch(this.items);
this.items = [];
}
}

22
cli/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"baseUrl": "./",
"types": ["vitest/globals"]
},
"exclude": ["dist", "node_modules"]
}

24
cli/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, UserConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
resolve: { alias: { src: '/src' } },
build: {
rollupOptions: {
input: 'src/index.ts',
output: {
dir: 'dist',
},
},
ssr: true,
},
ssr: {
// bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/,
},
plugins: [tsconfigPaths()],
test: {
name: 'cli:unit',
globals: true,
},
} as UserConfig);

4
deployment/.env Normal file
View File

@@ -0,0 +1,4 @@
export CLOUDFLARE_ACCOUNT_ID="op://tf/cloudflare/account_id"
export CLOUDFLARE_API_TOKEN="op://tf/cloudflare/api_token"
export TF_STATE_POSTGRES_CONN_STR="op://tf/tf_state/postgres_conn_str"
export TF_VAR_env=$ENVIRONMENT

38
deployment/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# OpenTofu
# Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
# Ignore CLI configuration files
.terraformrc
terraform.rc
# Terragrunt
# terragrunt cache directories
**/.terragrunt-cache/*
# Terragrunt debug output file (when using `--terragrunt-debug` option)
# See: https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-debug
terragrunt-debug.tfvars.json

20
deployment/mise.toml Normal file
View File

@@ -0,0 +1,20 @@
[tools]
terragrunt = "0.99.4"
opentofu = "1.11.5"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"
description = "Format terragrunt files"
[tasks.tf]
run = "terragrunt run --all"
description = "Wrapper for terragrunt run-all"
dir = "{{cwd}}"
[tasks."tf:fmt"]
run = "tofu fmt -recursive tf/"
description = "Format terraform files"
[tasks."tf:init"]
run = { task = "tf init -- -reconfigure" }
dir = "{{cwd}}"

View File

@@ -0,0 +1,38 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5"
constraints = "4.52.5"
hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}

View File

@@ -0,0 +1,11 @@
terraform {
backend "pg" {}
required_version = "~> 1.7"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.5"
}
}
}

View File

@@ -0,0 +1,14 @@
resource "cloudflare_pages_domain" "immich_app_release_domain" {
account_id = var.cloudflare_account_id
project_name = data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_name
domain = "docs.immich.app"
}
resource "cloudflare_record" "immich_app_release_domain" {
name = "docs.immich.app"
proxied = true
ttl = 1
type = "CNAME"
content = data.terraform_remote_state.cloudflare_immich_app_docs.outputs.immich_app_branch_pages_hostname
zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
}

View File

@@ -0,0 +1,3 @@
provider "cloudflare" {
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_docs
}

View File

@@ -0,0 +1,27 @@
data "terraform_remote_state" "api_keys_state" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_api_keys"
}
}
data "terraform_remote_state" "cloudflare_account" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_account"
}
}
data "terraform_remote_state" "cloudflare_immich_app_docs" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_immich_app_docs_${var.prefix_name}"
}
}

View File

@@ -0,0 +1,20 @@
terraform {
source = "."
extra_arguments custom_vars {
commands = get_terraform_commands_that_need_vars()
}
}
include {
path = find_in_parent_folders("state.hcl")
}
remote_state {
backend = "pg"
config = {
conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
schema_name = "prod_cloudflare_immich_app_docs_release"
}
}

View File

@@ -0,0 +1,4 @@
variable "cloudflare_account_id" {}
variable "tf_state_postgres_conn_str" {}
variable "prefix_name" {}

View File

@@ -0,0 +1,38 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5"
constraints = "4.52.5"
hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}

View File

@@ -0,0 +1,11 @@
terraform {
backend "pg" {}
required_version = "~> 1.7"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.5"
}
}
}

View File

@@ -0,0 +1,26 @@
resource "cloudflare_pages_domain" "immich_app_branch_domain" {
account_id = var.cloudflare_account_id
project_name = local.is_release ? data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_name : data.terraform_remote_state.cloudflare_account.outputs.immich_app_preview_pages_project_name
domain = "docs.${var.prefix_name}.${local.deploy_domain_prefix}.immich.app"
}
resource "cloudflare_record" "immich_app_branch_subdomain" {
name = "docs.${var.prefix_name}.${local.deploy_domain_prefix}.immich.app"
proxied = true
ttl = 1
type = "CNAME"
content = "${replace(var.prefix_name, "/\\/|\\./", "-")}.${local.is_release ? data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_subdomain : data.terraform_remote_state.cloudflare_account.outputs.immich_app_preview_pages_project_subdomain}"
zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
}
output "immich_app_branch_subdomain" {
value = cloudflare_record.immich_app_branch_subdomain.hostname
}
output "immich_app_branch_pages_hostname" {
value = cloudflare_record.immich_app_branch_subdomain.content
}
output "pages_project_name" {
value = cloudflare_pages_domain.immich_app_branch_domain.project_name
}

View File

@@ -0,0 +1,7 @@
locals {
domain_name = "immich.app"
preview_prefix = contains(["branch", "pr"], var.prefix_event_type) ? "preview" : ""
archive_prefix = contains(["release"], var.prefix_event_type) ? "archive" : ""
deploy_domain_prefix = coalesce(local.preview_prefix, local.archive_prefix)
is_release = contains(["release"], var.prefix_event_type)
}

View File

@@ -0,0 +1,3 @@
provider "cloudflare" {
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_docs
}

View File

@@ -0,0 +1,17 @@
data "terraform_remote_state" "api_keys_state" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_api_keys"
}
}
data "terraform_remote_state" "cloudflare_account" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_account"
}
}

View File

@@ -0,0 +1,24 @@
terraform {
source = "."
extra_arguments custom_vars {
commands = get_terraform_commands_that_need_vars()
}
}
include {
path = find_in_parent_folders("state.hcl")
}
locals {
prefix_name = get_env("TF_VAR_prefix_name")
}
remote_state {
backend = "pg"
config = {
conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
schema_name = "prod_cloudflare_immich_app_docs_${local.prefix_name}"
}
}

View File

@@ -0,0 +1,5 @@
variable "cloudflare_account_id" {}
variable "tf_state_postgres_conn_str" {}
variable "prefix_name" {}
variable "prefix_event_type" {}

20
deployment/state.hcl Normal file
View File

@@ -0,0 +1,20 @@
locals {
cloudflare_account_id = get_env("CLOUDFLARE_ACCOUNT_ID")
cloudflare_api_token = get_env("CLOUDFLARE_API_TOKEN")
tf_state_postgres_conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
}
remote_state {
backend = "pg"
config = {
conn_str = local.tf_state_postgres_conn_str
}
}
inputs = {
cloudflare_account_id = local.cloudflare_account_id
cloudflare_api_token = local.cloudflare_api_token
tf_state_postgres_conn_str = local.tf_state_postgres_conn_str
}

BIN
design/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Router_Medium_x5F_Black_00000159464448132936669960000002337362428709113490_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 266.25"
style="enable-background:new 0 0 792 266.25;" xml:space="preserve">
<style type="text/css">
.st0{fill:#ACCBFA;}
.st1{fill:#FA2921;}
.st2{fill:#ED79B5;}
.st3{fill:#FFB400;}
.st4{fill:#1E83F7;}
.st5{fill:#18C249;}
</style>
<g>
<path class="st0" d="M268.73,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
C257.04,68.36,262.39,63.18,268.73,63.18z M258.88,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
c5.84,0,10.52,4.67,10.52,10.68c0,2.84-0.83,7.68-0.83,10.68v38.73c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.52,10.68
c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
<path class="st0" d="M394.28,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
c0-2.84,0.83-7.68,0.83-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C399.12,182.55,394.28,177.88,394.28,171.87z"/>
<path class="st0" d="M528.5,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
c0-2.84,0.84-7.68,0.84-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C533.35,182.55,528.5,177.88,528.5,171.87z"/>
<path class="st0" d="M576.92,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
C565.23,68.36,570.57,63.18,576.92,63.18z M567.07,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
s10.52,4.67,10.52,10.68c0,2.84-0.84,7.68-0.84,10.68v38.73c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.52,10.68
s-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
<path class="st0" d="M601.79,141.31c0-23.54,14.69-42.57,39.07-42.57c12.86,0,24.71,5.84,30.05,14.53c2,3.17,2.34,5.01,2.34,6.51
c0,5.18-4.01,9.52-9.85,9.52c-3.84,0-7.34-2.17-8.85-6.01c-2.34-5.18-6.85-8.18-13.69-8.18c-12.86,0-20.03,11.52-20.03,26.04
c0,14.69,7.51,26.04,20.53,26.04c7.01,0,12.02-2.5,14.36-7.68c1.67-3.51,4.84-6.51,9.18-6.51c6.01,0,9.68,4.17,9.68,9.35
c0,2.5-1,5.51-3.17,8.35c-5.51,7.35-15.86,13.19-30.05,13.19C616.32,183.89,601.79,165.19,601.79,141.31z"/>
<path class="st0" d="M737.69,171.87c0-2.84,0.67-7.68,0.67-10.68v-28.55c0-10.18-5.68-17.2-15.36-17.2
c-6.68,0-12.35,3.17-16.03,8.35v37.4c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.67,10.68-10.52,10.68s-10.52-4.67-10.52-10.68
c0-2.84,0.84-7.68,0.84-10.68v-80.8c0-3.01-0.84-7.85-0.84-10.68c0-6.01,4.84-10.68,10.52-10.68c5.84,0,10.52,4.67,10.52,10.68
c0,2.84-0.67,7.68-0.67,10.68v27.21c5.01-5.51,12.19-8.85,21.37-8.85c17.2,0,29.55,12.86,29.55,31.22v31.22
c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68C742.36,182.55,737.69,177.88,737.69,171.87z"/>
</g>
<g>
<path class="st1" d="M114.82,96.21c11.92,10.55,21.52,21.86,27.7,32.52c10.62-18.99,17.71-41.55,17.8-55.92c0-0.1,0-0.19,0-0.28
c0-21.26-21.21-29.54-39.48-29.54s-39.48,8.28-39.48,29.54c0,0.29,0,0.68,0,1.15C91.54,78.2,103.61,86.29,114.82,96.21z"/>
<path class="st2" d="M49.8,154.19c7.45-8.29,18.88-17.27,31.77-24.86c13.72-8.07,27.44-13.71,39.49-16.3
c-14.78-15.96-34.04-29.68-47.68-34.21c-0.1-0.03-0.18-0.06-0.27-0.09c-20.22-6.57-34.65,11.05-40.3,28.42s-4.33,40.11,15.89,46.68
C48.99,153.93,49.35,154.05,49.8,154.19z"/>
<path class="st3" d="M209.07,106.86c-5.65-17.38-20.07-34.99-40.3-28.42c-0.28,0.09-0.65,0.21-1.09,0.35
c-1.16,11.08-5.12,25.07-11.09,38.79c-6.35,14.6-14.14,27.23-22.36,36.39c21.34,4.23,44.99,4,58.68-0.35
c0.1-0.03,0.19-0.06,0.27-0.09C213.4,146.97,214.71,124.24,209.07,106.86z"/>
<path class="st4" d="M102.8,171.18c-3.44-15.54-4.56-30.34-3.3-42.59c-19.75,9.12-38.75,23.2-47.27,34.78
c-0.06,0.08-0.11,0.16-0.16,0.23c-12.5,17.2-0.2,36.37,14.58,47.11s36.81,16.51,49.31-0.69c0.17-0.24,0.4-0.55,0.68-0.93
C111.05,199.44,106.04,185.79,102.8,171.18z"/>
<path class="st5" d="M189.48,162.49c-10.9,2.33-25.42,2.88-40.32,1.44c-15.84-1.53-30.26-5.03-41.52-10.02
c2.57,21.6,10.09,44.02,18.47,55.7c0.06,0.08,0.11,0.16,0.16,0.23c12.5,17.2,34.52,11.43,49.31,0.69
c14.78-10.74,27.08-29.9,14.58-47.11C189.99,163.18,189.76,162.86,189.48,162.49z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

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