Compare commits

...

341 Commits

Author SHA1 Message Date
alextran1502
2b200cee90 chore: release v2.4.0 2025-12-05 19:53:41 +00:00
idubnori
3c80049192 chore(mobile): add kebabu menu in asset viewer (#24387)
* feat(mobile): implement viewer kebab menu with about option

* feat: revert exisitng buttons, adjust label name

* unify MenuAnchor usage

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-05 19:51:59 +00:00
Hai Sullivan
8f1669efbe chore(mobile): smoother UI experience for iOS devices (#24397)
allows the tab pages to use the standard Material page transition during push/pop navigation
2025-12-05 11:02:04 -06:00
Robert Schäfer
146bf65d02 refactor(dev): remove ulimits for rootless docker (#24393)
Description
-----------

When I follow the [developer setup](https://docs.immich.app/developer/setup) I run into a permission error using rootless docker. A while ago I asked on Discord in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592) about these ulimits.

I suggest to remove the `ulimits` altogether. It seems that @ItalyPaleAle has left the setting just hoping that it could help somebody in the future. See the [PR description](https://github.com/immich-app/immich/pull/4556).

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

Using rootless docker:

```
$ docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

Running `make` will fail because of permission errors:
```
$  docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
...
Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error setting rlimits for ready process: error setting rlimit type 7: operation not permitted
```

On my machine I have the following hard limit for "Maximum number of open file descriptors":
```
$ ulimit -nH
524288
```

I can confirm that the permission error is caused by the security restrictions of the operating system mentioned above:

Changing `docker/docker-compose.dev.yml` like ..

```
    ulimits:
      nofile:
        soft: 524289
        hard: 524289
```

.. will lead to a permission error whereas this ..

```
    ulimits:
      nofile:
        soft: 524288
        hard: 524288
```

.. starts fine.

Apparently the defaults for these limits are coming from [systemd](26b2085d54/man/systemd.exec.xml (L1122)) which is used on nearly every linux distribution. So my assumption is that almost any linux user who uses rootless docker will run into a permission error when starting the development setup.

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

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-05 09:26:20 -05:00
Daniel Dietzler
75a7c9c06c feat: sql tools array as default value (#24389) 2025-12-04 12:54:20 -05:00
Daniel Dietzler
ae8f5a6673 fix: prettier (#24386) 2025-12-04 16:10:42 +00:00
Jason Rasmussen
31f2c7b505 feat: header context menu (#24374) 2025-12-04 11:09:38 -05:00
Yaros
ba6687dde9 feat(web): search type selection dropdown (#24091)
* feat(web): search type selection dropdown

* chore: implement suggestions

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-04 04:10:12 +00:00
shenlong
bbba1bfe8c fix: use adjustment time in iOS for hash reset (#24047)
* use adjustment time in iOS for hash reset

* migration

* fix equals check

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-03 21:15:58 -06:00
Robert Schäfer
4be9a5ebf8 fix(docs): obsolete docs about rootless docker (#24376)
Description
-----------

The documentation lies about comments in `docker/docker-compose.dev.yml`.

Reason: in 689c6aa276 these docs were added
but the comments in this file are removed in
b9e2590752 and the docs weren't updated.

How Has This Been Tested?
-------------------------
```
$ git log -S rootless

commit b9e2590752
Author: Jason Rasmussen <jason@rasm.me>
Date:   Tue Sep 16 12:48:44 2025 -0400

    chore: simplify (#22082)

commit 689c6aa276
Author: Rudolf Horváth <R-Rudolf@users.noreply.github.com>
Date:   Thu Nov 21 13:25:45 2024 +0100

    docs: add developer notes about rootless docker setup (#13250)
```

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

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-03 18:34:08 -06:00
Omar I
d41921247b fix(web): Add minimum content size to logo for consistent visual on small screens (#24372) 2025-12-03 21:35:48 +00:00
Nicholas
853a024f0f fix: prevent OOM on nginx reverse proxy servers (#24351)
Prevent OOM on reverse proxy servers

Added configuration to disable buffering for uploads.
2025-12-03 14:30:28 -06:00
Alex
4fe494776e fix: local full sync on Android on resume (#24348) 2025-12-03 20:22:07 +00:00
Justin Forseth
76b4adf276 fix: Adjust the zoom level (#24353)
Adjust the zoom level
2025-12-03 14:19:57 -06:00
Alex
75dde0d076 fix: exposure info and better readability (#24344)
fix: exposure info and better readabilit
2025-12-03 20:19:45 +00:00
Mert
cffb68d1c4 fix(server): do not delete offline assets (#24355)
* do not delete isOffline assets

* update sql

* add medium test

* add normal delete test

* formatting
2025-12-03 14:19:26 -06:00
Jason Rasmussen
45f68f73a9 feat: queue detail page (#24352) 2025-12-03 13:39:32 -05:00
renovate[bot]
4f93eda8d8 fix(deps): update typescript-projects (#24329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-02 23:28:12 +01:00
Alex
f5df5fa98d chore: change workflow column name (#24349)
chore-change-workflow-column-name
2025-12-02 14:40:17 -06:00
renovate[bot]
f07d1441ea chore(deps): update github-actions (#24331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 20:13:02 +01:00
Jonathan Jogenfors
1bcf28c062 chore(server): sidecars in asset_files (#21199)
* fix: sidecar check job

* feat: move sidecars to asset_files

* feat: combine with handleSidecarCheck

* fix(server): improved method signatures for stack and sidecar copying

* fix(server): improved method signatures for stack and sidecar copying

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-12-02 13:31:43 -05:00
Jonathan Jogenfors
62628dfcfa fix(web): folder view sort oder (#24337)
fix: folder view sort oder
2025-12-02 11:48:12 -06:00
Hai Sullivan
b11aecd184 fix(mobile): use correct timezone displayed in the info sheet (#24310)
* fixed the timezone issue in the Immich mobile app's metadata sheet to match the web app's behavior

* format dart

* now uses the shared applyTimezoneOffset() utility function from mobile/lib/utils/timezone.dart

* add tests

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-02 16:37:19 +00:00
Jason Rasmussen
116012f6f8 feat: less asset-metadata validation (#24342) 2025-12-02 10:56:31 -05:00
renovate[bot]
7594136050 chore(deps): update dependency express to v5.2.0 [security] (#24323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:27:12 -05:00
renovate[bot]
bb341cc774 chore(deps): update docker.io/valkey/valkey docker tag to v9 (#24336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:26:39 -05:00
Dionysius
af1d4afb95 fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT (#24335) 2025-12-02 09:25:39 -05:00
renovate[bot]
75b1ef2c57 chore(deps): update machine-learning (#24334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 01:01:17 -05:00
renovate[bot]
1e37f7c8c8 chore(deps): update dependency nodemailer to v7.0.11 [security] (#24330)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 23:16:59 -05:00
Yaros
a32f450059 feat(mobile): persist album sorting & layout in settings (#22133)
* fix(mobile): persist album sorting in settings

* fix(mobile): persist album layout

* fix: fixed store model id

* fix: corrupted AppSettingsEnum

* chore: refactor to remove RemoteAlbumSortMode

* refactor: use t instead of tr
2025-12-01 20:51:35 -06:00
carbonemys
b452ab463b fix(web): open onboarding documentation link in new tab (#24289)
* fix(web): open onboarding documentation link in new tab

* Update web/src/lib/components/onboarding-page/onboarding-storage-template.svelte

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-01 20:49:31 -06:00
Sergey Katsubo
79bed80226 feat(server): log all thumbnail generation attempts at verbose level (#24324)
Log thumbnail generation at verbose level
2025-12-01 20:26:13 -06:00
Mert
6249996cdb fix(ml): do not upscale preview (#24322)
do not upscale
2025-12-01 20:26:01 -06:00
Jonathan Jogenfors
a3f281caa3 docs(faq): add more info on archiving (#24326)
docs: add more info on archive to faq
2025-12-01 20:25:31 -06:00
Mert
7c19b0591f fix(server): cjk migration (#24320)
* join string

* use pagination instead
2025-12-01 15:41:19 -06:00
Mert
95c29a8aea fix(server): use bigrams for cjk (#24285)
* use bigrams for cjk

* update sql

* linting

* actually migrate ocr

* fix backwards test

* use array

* tweaks
2025-12-01 17:24:37 +00:00
idubnori
d8ca210641 chore(web): minor UX improvements of "view asset owners" feature (#24319)
* feat: toggle in options modal

* feat(i18n): add labels to display who uploaded each asset and show asset owners

* feat: migrate asset owner settings to TimelineManager and update AlbumOptionsModal

* Revert "feat(i18n): add labels to display who uploaded each asset and show asset owners"

This reverts commit cf8f4eb135.

* fix: simplify AlbumOptionsModal invocation and update aria-label for asset owners

* feat(i18n): add label for viewing asset owners in the interface

* feat: add tests for showAssetOwners functionality in TimelineManager

* chore: move asset owner visibility toggle to kebabu menu
2025-12-01 10:25:12 -06:00
Min Idzelis
ab35afd3b1 refactor(web): reimplement operation-support as part of timeline-manager (#24056)
* refactor(web): reimplement operation-support as part of timeline-manager

Improve clarity of methods. 
Add inline method documentation.  
Make return type of AssetOperation optional.

* Review comments - self document code. remove optional return from callback
2025-12-01 09:04:39 -06:00
idubnori
65e4fdf98d refactor(web): i18n-ize "view asset owners" (#24317) 2025-12-01 15:01:57 +00:00
Matthew Momjian
fa43fae2a5 fix(mobile): docs link (#24277)
update docs link
2025-11-30 13:01:33 -06:00
Alex
46afd6a101 fix: only generate memory based on users assets (#24151) 2025-11-30 13:01:12 -06:00
Hai Sullivan
46e1967760 chore: optimisation of several UI components of the mobile app (#24098)
* fix(mobile): normalize scrolling behavior in networking settings

Remove ClampingScrollPhysics from networking settings page to match
the scrolling behavior of other settings pages. This restores the
standard iOS bounce/elastic scrolling effect.

* fix(mobile): use consistent native transitions for Library pages

Change Trash, Shared Links, and Folders routes from CustomRoute to AutoRoute to enable native iOS transitions with swipe-back gesture support.

* fix(mobile): remove SafeArea wrapper and ClampingScrollPhysics from Settings

Remove SafeArea wrapper (Scaffold handles safe areas automatically) and ClampingScrollPhysics to enable native iOS bounce scrolling.

* fix(mobile): remove bottom white space in Sync Status page

Replace Padding wrapper with ListView padding to match other Settings pages and eliminate bottom white space.

* chore: fix Dart formatting

Run dart format to fix formatting issues in settings.page.dart and sync_status_and_actions.dart

* Format Dart files

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: kao-byte <benjaminliu@MacBook-Air.local>
2025-11-30 13:01:01 -06:00
Chris Peckover
922282b2b4 feat(web): Shared album owner labels (#21171)
* - pass available album users along to the thumbnail through the asset-date-group
- show a small user-avatar in bottom right of thumbnail

* - change owner to their name in white text instead of the avatar

* cleanup

* - cleanup albumUsers creation
- use font-light for the user's name

* fix lint

* format

* - add toggle to show/hide asset owner names

* update new Timeline with albumUsers

* add @idubnori suggestion for the name font

* Don't show 'view owners' button if the album doesn't have editors

* add missing import

* format

* fix(web): #21171 (#24298)

fix: Bind timelineManager to Timeline component

---------

Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-30 18:56:03 +00:00
Alex
e3ab16a5bd chore: refactor mobile events (#24263)
chore: refactor mobile evets
2025-11-30 12:43:33 -06:00
Niklas von Moers
08f320c801 fix(web): use full tag path when creating nested subtags (#24249) 2025-11-29 12:09:32 +00:00
Mees Frensel
e36261b552 fix(web): integrate zoom toggle button into panorama photo viewer (#24189) 2025-11-28 18:50:16 +01:00
Daniel Dietzler
c0a3b58bba fix: rare cases of assets not loading in when scrolling backwards (#24245) 2025-11-28 10:18:49 -06:00
Yaros
f12f609038 fix(mobile): enable backup text overflows (#24227) 2025-11-28 10:18:44 -06:00
renovate[bot]
1f6eb662e5 chore(deps): update dependency opentofu to v1.10.7 (#23964)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 14:41:23 +00:00
renovate[bot]
0c1fe35f2f chore(deps): update dependency terragrunt to v0.93.10 (#24149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 15:17:25 +01:00
Robert Schäfer
e98a33cf9d fix(docs): build cli for e2e tests (#24184) 2025-11-28 15:11:17 +01:00
Dionysius
d38305360c docs: DB_STORAGE_TYPE is only used by the database container (#24215)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-28 12:43:48 +00:00
Tijs
3e3ca4c104 feat(server): exclude syncthing folders from external libraries (#24240)
* Add SyncThing folders to External library exclusion

SyncThing is a popular library for syncing files (like pictures) between systems. It can really mess up your library if an external library, which is also used by SyncThing, is added and these folders are not excluded.

* Plural

* fix formatting

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2025-11-28 11:40:33 +00:00
Jacob Bundgaard
81edf0749f fix: label 'for' attributes in user-api-key-grid (#24232) 2025-11-27 23:28:38 +00:00
renovate[bot]
01f83ae964 fix(deps): update dependency exiftool-vendored to v33 (#24172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:45:35 +00:00
renovate[bot]
5eec0dc981 chore(deps): update github-actions (#24038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 21:41:41 +00:00
renovate[bot]
ca4fd07656 chore(deps): update dependency eslint-plugin-unicorn to v62 (#24167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:31:16 +00:00
renovate[bot]
7ce43b3824 chore(deps): update dependency node-gyp to v12 (#24168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 16:13:57 +01:00
Daniel Dietzler
ce00119926 chore: update sharp to 0.34.5 (#24170) 2025-11-27 15:13:16 +00:00
Daniel Dietzler
fffee80e2f feat: command palette (#23693) 2025-11-26 22:18:50 +01:00
Jason Rasmussen
64cd4e96e3 fix: theme switcher (#24209) 2025-11-26 21:17:26 +00:00
renovate[bot]
955a3bfaa6 chore(deps): update base-image to v202511261514 (major) (#24165)
chore(deps): update base-image to v202511261514

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 15:10:48 -05:00
renovate[bot]
e699d8f170 chore(deps): update machine-learning (#23352) 2025-11-26 19:09:39 +00:00
Jason Rasmussen
13104d49cd feat(web): shared link card tweaks (#24192) 2025-11-25 19:35:21 -06:00
Jason Rasmussen
2d5ec528d5 fix(web): user admin pages (#24185)
fix: user admin pages
2025-11-25 16:35:37 -05:00
Min Idzelis
5226898184 fix: update timeline-manager after archive actions (#24010)
* fix: update timeline-manager after archive actions

* Add locators to thumb icons
2025-11-25 15:06:29 -05:00
Luka Prebil Grintal
dd4169876c fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures (#24045)
* fix: update ONNX runtime version to 1.21.0 to fix the failing checksum of 1.20.1

* update patch

* update to 1.22.1

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-11-25 13:27:21 -05:00
renovate[bot]
8321c275b8 chore(deps): update dependency body-parser to v2.2.1 [security] (#24179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 18:29:13 +01:00
renovate[bot]
3d6c26350a fix(deps): update typescript-projects (#24163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-25 17:26:36 +00:00
Jason Rasmussen
db15e5e423 fix: duration extraction (#24178) 2025-11-25 10:26:25 -05:00
renovate[bot]
35d18da14a chore(deps): update node to v24 (major) (#24169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:48 +01:00
renovate[bot]
cb56a11f0b chore(deps): update dependency @types/archiver to v7 (#24166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:12 +01:00
Jason Rasmussen
104fa09f69 feat: queues (#24142) 2025-11-25 08:19:40 -05:00
Alex
66ae07ee39 fix: don't get OCR data in shared link (#24152) 2025-11-25 07:58:27 -05:00
Daniel Dietzler
939d2c8b27 chore: minor admin pages refactorings (#24160) 2025-11-25 07:57:30 -05:00
renovate[bot]
2801a6e672 chore(deps): update actions/download-artifact action to v6 (#24164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:46:34 +01:00
renovate[bot]
4742360469 chore(deps): update grafana/grafana docker tag to v12.3.0 (#24162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:45:50 +01:00
Daniel Dietzler
b56fa62b32 fix: revert "chore(deps): update dependency sharp to v0.34.5" (#24173) 2025-11-25 12:37:08 +00:00
renovate[bot]
ddbe485074 chore(deps): update dependency sharp to v0.34.5 (#24146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:56:11 +01:00
renovate[bot]
01310c6d86 chore(deps): update node.js to v24.11.1 (#24147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:55:36 +01:00
Brandon Wees
512327ef69 feat: java in mise (#24154) 2025-11-24 23:18:44 -06:00
Daniel Dietzler
8755cd59fd chore: refactor svelte reactivity (#24072) 2025-11-24 18:57:46 -05:00
Min Idzelis
7694b342ed refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components (#23338)
* refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components

* chore: cleanup

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-24 23:09:46 +00:00
fabianbees
78553a0258 feat: separate camera and lens info in detail panel (#23670)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-24 16:30:15 +00:00
renovate[bot]
c1198b99b7 chore(deps): update dependency js-yaml to v4.1.1 [security] (#23901)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 17:28:18 +01:00
renovate[bot]
8b7b9ee394 chore(deps): update dependency esbuild to ^0.25.0 [security] (#23903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 17:27:46 +01:00
Min Idzelis
d6b39a464d feat: improve performance: don't sort timeline buckets from server (#24032) 2025-11-24 17:26:52 +01:00
Snowknight26
75d23fe135 fix(web): fix support & feedback modal wrapping (#24018)
* fix(web): fix support & feedback modal wrapping

* Fix reference
2025-11-24 10:24:02 -06:00
shenlong
c860809aa1 fix: getAspectRatio fallback to db width and height (#24131)
fix: getExif fallback to db width and height

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-24 10:23:17 -06:00
Daniel Dietzler
0498f6cb9d fix: albums page reactivity loops (#24046) 2025-11-24 17:14:24 +01:00
shenlong
24e5dabb51 fix: use proper updatedAt value in local assets (#24137)
* fix: incorrect updatedAt value in local assets

* add test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-24 09:49:27 -06:00
Greg Lutostanski
aecf064ec9 fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params (#23333)
Co-authored-by: Greg Lutostanski <greg.lutostanski@mobilityhouse.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-24 16:34:21 +01:00
Daniel Dietzler
57be3ff8c7 fix: add users to album (#24133) 2025-11-24 07:52:36 -05:00
Ujjwal Goel
99505f987e fix: use npm instead of pnpm and fix check:all (#24101)
* fix: use npm instead of pnpm and fix `check:all`

* fix: remove `--` from pnpm commands

* Remove `check:all` from the documentation section
2025-11-23 21:04:43 -06:00
Lukas
1e1c4ac9d2 feat(web): allow navigating the map with arrow keys (#24080) 2025-11-21 23:46:30 +01:00
Mees Frensel
d952b62053 feat(web): show detected faces in spherical photos (#23974) 2025-11-21 09:11:47 -06:00
Yaros
9f3eeed091 fix(mobile): first video memory on page doesn't play (#23906)
* fix(mobile): first video memory doesn't play

* refactor: moved logic to static method

* refactor: fix haptic feedback & empty check

* refactor: use DriftMemory on setMemory

* refactor: move video reset into if block
2025-11-21 09:11:30 -06:00
Brandon Wees
1dbc20fd77 fix: show archived assets in favorite page (#24052) 2025-11-21 09:09:16 -06:00
Joren Guillaume
ba8df712c4 fix: Use correct app store link (#24062) 2025-11-21 13:54:09 +01:00
renovate[bot]
741d838f56 chore(deps): update base-image to v202511181104 (major) (#24050)
chore(deps): update base-image to v202511181104

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 15:40:40 -06:00
Brandon Wees
ec2fa6e308 fix: disable animation "add to" action menu (#24040) 2025-11-20 11:54:15 -06:00
shenlong
b974ed5735 fix: do not clear hash on updated_at change (#24039)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-20 11:31:17 -06:00
Daniel Dietzler
78457d9b89 chore: add new language requests (#23991) 2025-11-20 08:58:18 -05:00
bo0tzz
5d043b435e fix: hardcoded build-cache pull for e2e tests (#24002) 2025-11-20 14:22:45 +01:00
Jason Rasmussen
9a403d5886 refactor(web): user delete websocket event (#24015) 2025-11-20 07:54:29 -05:00
Jason Rasmussen
1a31faf1a2 fix: effect loop (#24014) 2025-11-20 07:52:37 -05:00
github-actions
edbdc14178 chore: version v2.3.1 2025-11-20 02:20:16 +00:00
Alex
e7261a04e1 fix: new update notification cause rendering loop (#24013) 2025-11-19 20:14:30 -06:00
Jason Rasmussen
acded69adf fix: supporter badge (#24012) 2025-11-19 20:14:15 -06:00
github-actions
45a0315606 chore: version v2.3.0 2025-11-19 17:46:53 +00:00
Weblate (bot)
3856d4053c chore(web): update translations (#23449)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/be/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bi/
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/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fa/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/
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/ko/
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/mr/
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/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/ro/
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/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ta/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/te/
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/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

Co-authored-by: 100daysummer <bobbydochev@gmail.com>
Co-authored-by: Abhijeet Bonde <abhijeetbonde19@gmail.com>
Co-authored-by: AbuKareem Tuffaha <abukareem.tuffaha@gmail.com>
Co-authored-by: Adam Uchmanowicz <auchmanowicz@gmail.com>
Co-authored-by: Adrian Jost <github@adrianjost.dev>
Co-authored-by: Aitor-RM <Aitor.Rufian@alu.uclm.es>
Co-authored-by: Alexander Lohnes <alex.lohnes@googlemail.com>
Co-authored-by: Alexis-Loskoutoff <alexis@pctraining.fr>
Co-authored-by: Alma Hassan <almahassan9988@gmail.com>
Co-authored-by: AndreiP28 <andreiprica28@gmail.com>
Co-authored-by: Artur Koziara <arturkoziara@gmail.com>
Co-authored-by: Bryan Saputra <bryananta@icloud.com>
Co-authored-by: Carlo_Mava <carlomavaracchio@gmail.com>
Co-authored-by: Cristian Florin Tănase <crissssty@gmail.com>
Co-authored-by: Cristiano Fagundes <fagundescristianof@gmail.com>
Co-authored-by: Daniel Rieiro <daniel@danielrieiro.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fred <freddyfunk@users.noreply.hosted.weblate.org>
Co-authored-by: Hossein Fani <linr@users.noreply.hosted.weblate.org>
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: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Jesús Jiménez <jesjimenez@gmail.com>
Co-authored-by: Johannes Dorn <johannes@dorn.email>
Co-authored-by: Jordy H <jordy@hoebergen.net>
Co-authored-by: Jorge Tristan <trisjor1998@gmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Juanma Sanchez <juxmix@gmail.com>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Kai Heine <kai-heine@users.noreply.hosted.weblate.org>
Co-authored-by: Knight Hat <knightchanelgaming@gmail.com>
Co-authored-by: Krissada Singhakachain <46844213+OmsinKrissada@users.noreply.github.com>
Co-authored-by: Leigh van der merwe <palitu822@gmail.com>
Co-authored-by: Leo Bottaro <github@leobottaro.com>
Co-authored-by: Luca Segato <luspy89@hotmail.it>
Co-authored-by: Lucas Jaksys <lucas3033@gmail.com>
Co-authored-by: Luís Nunes <lmcnunes@gmail.com>
Co-authored-by: Macgyver <macgyver@users.noreply.hosted.weblate.org>
Co-authored-by: Marc Casillas <mcasillassu@gmail.com>
Co-authored-by: Marco Perrotta <leondaval18@gmail.com>
Co-authored-by: MatijaThe245th <matija245matakovic@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Matteo D. <alex3025game@gmail.com>
Co-authored-by: Matteo De Carli <matteo.de.carli01@gmail.com>
Co-authored-by: Mees Frensel <meesfrensel@gmail.com>
Co-authored-by: Melvin Snijders <mail@melvinsnijders.nl>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Parms <shoppingpar+weblate@simplelogin.com>
Co-authored-by: Paul <paul.kunad@kabelmail.de>
Co-authored-by: Petri Hämäläinen <petri.hamalainen@mailbox.org>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: Pitoune <p.dhebrail@proton.me>
Co-authored-by: Ponas <le.slab124@aleeas.com>
Co-authored-by: PontusÖsterlindh <pontus@osterlindh.com>
Co-authored-by: Rasmus Sehlin <rasmus@sehl.in>
Co-authored-by: Richard Gráčik <r.gracik@gmail.com>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: Runskrift <anders@rimfrost.nu>
Co-authored-by: Ryan Gleeson <gleeson.ryanj@gmail.com>
Co-authored-by: S M, Aravinth (A.) <asm1@ford.com>
Co-authored-by: Sai Athulith Neela <saiathulithn@gmail.com>
Co-authored-by: Sebastiano <sebastiano.romi@gmail.com>
Co-authored-by: Sergey Katsubo <skatsubo@gmail.com>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Tanishq <weblate.impure434@passinbox.com>
Co-authored-by: Tatsuhiko Kono <kono@takenoko.io>
Co-authored-by: Tedy25879 <tedy25879@gmail.com>
Co-authored-by: Thanh Tùng Nguyễn <tung.nguyent03@gmail.com>
Co-authored-by: Toine Rademacher <hi@toine.zip>
Co-authored-by: Tomi Pöyskö <tomi.poysko@gmail.com>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Vegard Fladby <vegard@fladby.org>
Co-authored-by: anton garcias <isaga.percompartir@gmail.com>
Co-authored-by: eav5jhl0 <eav5jhl0@users.noreply.hosted.weblate.org>
Co-authored-by: gablilli <gabriele.lilli0511@gmail.com>
Co-authored-by: kiwinho <kiwicaja@gmail.com>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: r64 <me@ruka64.dev>
Co-authored-by: ruka-64 <202770393+ruka-64@users.noreply.github.com>
Co-authored-by: sam ng <andy.sam@gmail.com>
Co-authored-by: sh4tteredd <llor22658@gmail.com>
Co-authored-by: shiuh67 <shiuh.cheng@gmail.com>
Co-authored-by: swever <swever@users.noreply.hosted.weblate.org>
Co-authored-by: thehijacker <thehijacker@gmail.com>
Co-authored-by: ti-guru <anders.egeland@outlook.com>
Co-authored-by: ume <bungoume@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
2025-11-19 17:44:39 +00:00
bo0tzz
8175b3b75b fix: allow adding new translations files (#23998) 2025-11-19 16:00:01 +00:00
Alex
56e431226f feat: show OCR bounding box (#23717)
* feat: ocr bounding box

* bounding boxes

* pr feedback

* pr feedback

* allow copy across text boxes

* pr feedback
2025-11-19 15:52:40 +00:00
Daniel Dietzler
f59417cc77 chore(web): refactor replace asset (#23972) 2025-11-19 08:49:15 -06:00
Min Idzelis
11cec56e80 refactor(web): consolidate timeline API - merge addAssets/updateAssets into upsertAssets (#23985) 2025-11-19 08:48:16 -06:00
bo0tzz
810f22057c fix: create release as draft (#23996) 2025-11-19 14:46:14 +00:00
Min Idzelis
2152f20b6c fix: unarchive action doesn't update archive page (#23987) 2025-11-19 12:29:02 +01:00
Matthew Momjian
a6c76e78d6 fix(docs): update Readme links (#23959)
* fix(docs): Update star history links and image sources

* Update star history link in README.md
2025-11-18 21:32:11 -06:00
renovate[bot]
644a3bf090 chore(deps): update github-actions (#23962)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 21:32:00 -06:00
Min Idzelis
42dd3315f8 refactor(web): fix TimelineManager import - use value import instead of type-only (#23983) 2025-11-18 21:26:15 -06:00
Kevin Puertas
3a694219bf feat: add originalPath for external library assets in dedupe (#23710)
* Add original path info row to duplicate asset component

View path of images, useful when using external Library

* Make if for not show path in internal images

* Update web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte

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

* Refactor original path display logic in duplicate-asset

* Update duplicate-asset.svelte

* Add full path localization string

* Change translated data

* format: fix

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-11-19 03:24:17 +00:00
Min Idzelis
d9fd52ea18 feat: timeline e2e tests (#23895)
* feat: timeline e2e tests

* Fix flakiness, mock all apis, allow parallel tests

* Upload playwright reports

* wrong report path

* Add CI=true, disable flaky/failing tests

* Re-enable tests, fix worker thread config

* fix maintance e2e test

* increase retries
2025-11-18 21:08:55 -06:00
Brandon Wees
2a281e7906 feat(mobile): location edit from asset viewer (#23925)
* chore: break sheet tile into own file

* feat: set location from bottom sheet

* refactor: location picker

There was a lot of confusing controls here, simplified to 1 mode

* fix: local asset check

* chore: refactoring of location details widget

* fix: update currentAssetExifProvider when changing location

* chore: use SheetTile for location header

* chore: remove coordinate change check

* chore: remove comment
2025-11-18 21:06:51 -06:00
Daniel Dietzler
5f987a95f5 fix: feature flags manager race condition (#23973)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-19 03:05:53 +00:00
bo0tzz
edf577d7f7 feat: publish on release pr merge (#23867) 2025-11-18 21:03:49 -06:00
Sergey Katsubo
5e482dabc6 feat(server): support running medium tests in devcontainer (#23882)
* Support running medium tests in devcontainer

* Add "pnpm run test:medium" to the devcontainer doc

* Fix indentation for inline comments in the doc

* Fix a couple of words in the doc
2025-11-18 21:03:21 -06:00
Mees Frensel
76c73549ae feat(web): always view original of animated images (#23842) 2025-11-18 21:02:52 -06:00
Mees Frensel
271a42ac7f fix(server): copy relevant panorama tags to preview image (#23953) 2025-11-19 03:02:12 +00:00
bo0tzz
4462952564 fix: proper docker caching for plugin mise deps (#23967)
* fix: proper docker caching for plugin mise deps

* fix: mount mise cache for build too
2025-11-19 03:00:03 +00:00
shenlong
38d4d1a573 chore: reset remote sync on app update (#23969)
reset remote sync on update

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-19 02:55:01 +00:00
Daniel Dietzler
d310c6f3cd feat: library details page (#23908)
* feat: library details page

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-11-18 15:27:41 -05:00
Alex
c086a65fa8 chore: update drift (#23877)
* chore: update drift

* update drift dep

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-18 16:07:33 +00:00
renovate[bot]
7134dd29ca chore(deps): pin ghcr.io/jdx/mise docker tag to ac26f59 (#23961)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:21:28 +01:00
renovate[bot]
3e08953a43 chore(deps): update dependency @types/node to ^22.19.1 (#23963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:20:52 +01:00
Min Idzelis
58c3c7e26b feat: run e2e server in dev mode (#23921)
* feat: run e2e server in dev mode

* Use bash syntax: [[ and ==
2025-11-17 14:16:39 -06:00
Min Idzelis
237ddcb648 fix: incorrect header height calculation in estimated month height (#23923) 2025-11-17 14:14:06 -06:00
Min Idzelis
fbaeffd65c fix: flaky user-admin.e2e-spec.ts (#23929)
* fix: flaky user-admin.e2e-spec.ts

* lint
2025-11-17 14:12:44 -06:00
Min Idzelis
d64c339b4f fix: null dereference when canceling bucket in album (#23924) 2025-11-17 14:12:28 -06:00
Min Idzelis
69880ee165 fix: deep link to last asset (#23920) 2025-11-17 14:12:07 -06:00
Paul Makles
15e00f82f0 feat: maintenance mode (#23431)
* feat: add a `maintenance.enabled` config flag

* feat: implement graceful restart
feat: restart when maintenance config is toggled

* feat: boot a stripped down maintenance api if enabled

* feat: cli command to toggle maintenance mode

* chore: fallback IMMICH_SERVER_URL environment variable in process

* chore: add additional routes to maintenance controller

* fix: don't wait for nest application to close to finish request response

* chore: add a failsafe on restart to prevent other exit codes from preventing restart

* feat: redirect into/from maintenance page

* refactor: use system metadata for maintenance status

* refactor: wait on WebSocket connection to refresh

* feat: broadcast websocket event on server restart
refactor: listen to WS instead of polling

* refactor: bubble up maintenance information instead of hijacking in fetch function
feat: show modal when server is restarting

* chore: increase timeout for ungraceful restart

* refactor: deduplicate code between api/maintenance workers

* fix: skip config check if database is not initialised

* fix: add `maintenanceMode` field to system config test

* refactor: move maintenance resolution code to static method in service

* chore: clean up linter issues

* chore: generate dart openapi

* refactor: use try{} block for maintenance mode check

* fix: logic error in server redirect

* chore: include `maintenanceMode` key in e2e test

* chore: add i18n entries for maintenance screens

* chore: remove negated condition from hook

* fix: should set default value not override in service

* fix: minor error in page

* feat: initial draft of maintenance module, repo., worker controller, worker service

* refactor: move broadcast code into notification service

* chore: connect websocket on client if in maintenance

* chore: set maintenance module app name

* refactor: rename repository to include worker
chore: configure websocket adapter

* feat: reimplement maintenance mode exit with new module

* refactor: add a constant enum for ExitCode

* refactor: remove redundant route for maintenance

* refactor: only spin up kysely on boot (rather than a Nest app)

* refactor(web): move redirect logic into +layout file where modal is setup

* feat: add Maintenance permission

* refactor: merge common code between api/maintenance

* fix: propagate changes from the CLI to servers

* feat: maintenance authentication guard

* refactor: unify maintenance code into repository
feat: add a step to generate maintenance mode token

* feat: jwt auth for maintenance

* refactor: switch from nest jwt to just jsonwebtokens

* feat: log into maintenance mode from CLI command

* refactor: use `secret` instead of `token` in jwt terminology
chore: log maintenance mode login URL on boot
chore: don't make CLI actions reload if already in target state

* docs: initial draft for maintenance mode page

* refactor: always validate the maintenance auth on the server

* feat: add a link to maintenance mode documentation

* feat: redirect users back to the last page they were on when exiting maintenance

* refactor: provide closeFn in both maintenance repos.

* refactor: ensure the user is also redirected by the server

* chore: swap jsonwebtoken for jose

* refactor: introduce AppRestartEvent w/o secret passing

* refactor: use navigation goto

* refactor: use `continue` instead of `next`

* chore: lint fixes for server

* chore: lint fixes for web

* test: add mock for maintenance repository

* test: add base service dependency to maintenance

* chore: remove @types/jsonwebtoken

* refactor: close database connection after startup check

* refactor: use `request#auth` key

* refactor: use service instead of repository
chore: read token from cookie if possible
chore: rename client event to AppRestartV1

* refactor: more concise redirect logic on web

* refactor: move redirect check into utils
refactor: update translation strings to be more sensible

* refactor: always validate login (i.e. check cookie)

* refactor: lint, open-api, remove old dto

* refactor: encode at point of usage

* refactor: remove business logic from repositories

* chore: fix server/web lints

* refactor: remove repository mock

* chore: fix formatting

* test: write service mocks for maintenance mode

* test: write cli service tests

* fix: catch errors when closing app

* fix: always report no maintenance when usual API is available

* test: api e2e maintenance spec

* chore: add response builder

* chore: add helper to set maint. auth cookie

* feat: add SSR to maintenance API

* test(e2e): write web spec for maintenance

* chore: clean up lint issues

* chore: format files

* feat: perform 302 redirect at server level during maintenance

* fix: keep trying to stop immich until it succeeds (CLI issue)

* chore: lint/format

* refactor: annotate references to other services in worker service

* chore: lint

* refactor: remove unnecessary await

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

* refactor: move static methods into util

* refactor: assert secret exists in maintenance worker

* refactor: remove assertion which isn't necessary anymore

* refactor: remove assertion

* refactor: remove outer try {} catch block from loadMaintenanceAuth

* refactor: undo earlier change to vite.config.ts

* chore: update tests due to refactors

* revert: vite.config.ts

* test: expect string jwt

* chore: move blanket exceptions into controllers

* test: update tests according with last change

* refactor: use respondWithCookie
refactor: merge start/end into one route
refactor: rename MaintenanceRepository to AppRepository
chore: use new ApiTag/Endpoint
refactor: apply other requested changes

* chore: regenerate openapi

* chore: lint/format

* chore: remove secureOnly for maint. cookie

* refactor: move maintenance worker code into src/maintenance\nfix: various test fixes

* refactor: use `action` property for setting maint. mode

* refactor: remove Websocket#restartApp in favour of individual methods

* chore: incomplete commit

* chore: remove stray log

* fix: call exitApp from maintenance worker on exit

* fix: add app repository mock

* fix: ensure maintenance cookies are secure

* fix: run playwright tests over secure context (localhost)

* test: update other references to 127.0.0.1

* refactor: use serverSideEmitWithAck

* chore: correct the logic in tryTerminate

* test: juggle cookies ourselves

* chore: fix lint error for e2e spec

* chore: format e2e test

* fix: set cookie secure/non-secure depending on context

* chore: format files

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-17 17:15:44 +00:00
Jason Rasmussen
ce82e27f4b fix: workflow medium tests (#23952) 2025-11-17 16:26:30 +00:00
Yaros
eeee5147cc fix(mobile): delete from device warning shows incorrectly (#23935)
fix(mobile): delete warning on multiple assets
2025-11-17 10:17:04 -06:00
100daysummer
af22f9b014 fix: word wrap on custom link preview (#23942)
Word break fix in create link

Adds the "break-all" tailwind style to the slug text under the custom link text box
2025-11-17 08:49:32 -05:00
Paul Makles
1086f22166 fix: devcontainer server not starting due to missing plugins mount (#23948) 2025-11-17 12:24:59 +01:00
Christian
e94eb5012f feat(mobile): add to album from asset viewer (#23608)
* feat: add action button in photo viewer for adding assets to albums, archiving, and moving to locked folders

* fix: use const constructors for icons in action button menu

* Update mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart

Co-authored-by: Brandon Wees <brandonwees@gmail.com>

* Update mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart

Co-authored-by: Brandon Wees <brandonwees@gmail.com>

* remove de translation

* fixed PR comments: https://github.com/immich-app/immich/pull/23608

* menu styling

* menu styling

* i18n

---------

Co-authored-by: Brandon Wees <brandonwees@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-14 15:11:47 -06:00
Alex
4dcc049465 feat: workflow foundation (#23621)
* feat: plugins

* feat: table definition

* feat: type and migration

* feat: add repositories

* feat: validate manifest with class-validator and load manifest info to database

* feat: workflow/plugin controller/service layer

* feat: implement workflow logic

* feat: make trigger static

* feat: dynamical instantiate plugin instances

* fix: access control and helper script

* feat: it works

* chore: simplify

* refactor: refactor and use queue for workflow execution

* refactor: remove unsused property in plugin-schema

* build wasm in prod

* feat: plugin loader in transaction

* fix: docker build arm64

* generated files

* shell check

* fix tests

* fix: waiting for migration to finish before loading plugin

* remove context reassignment

* feat: use mise to manage extism tools (#23760)

* pr feedback

* refactor: create workflow now including create filters and actions

* feat: workflow medium tests

* fix: broken medium test

* feat: medium tests

* chore: unify workflow job

* sign user id with jwt

* chore: query plugin with filters and action

* chore: read manifest in repository

* chore: load manifest from server configs

* merge main

* feat: endpoint documentation

* pr feedback

* load plugin from absolute path

* refactor:handle trigger

* throw error and return early

* pr feedback

* unify plugin services

* fix: plugins code

* clean up

* remove triggerConfig

* clean up

* displayName and methodName

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-11-14 20:05:05 +00:00
Jason Rasmussen
d784d431d0 refactor: job vs queue naming (#23902) 2025-11-14 14:42:00 -05:00
Daniel Dietzler
1200bfad13 refactor: server config and feature flags managers (#23894) 2025-11-14 14:10:44 -05:00
Jason Rasmussen
f11bfb9581 fix(server): broken memories (#23896) 2025-11-14 11:46:32 -05:00
Daniel Dietzler
074fdb2b96 fix: out of sync pnpm lockfile (#23891) 2025-11-14 12:13:09 +01:00
Daniel Dietzler
f1f203719d refactor: admin settings (#23843) 2025-11-13 13:17:44 -05:00
zebrapurring
f73ca9d9c0 chore: build bcrypt dependency from source (#22145)
This may provide better performance on some cases and guarantee cross-platform compatibility

Co-authored-by: zebrapurring <>
2025-11-13 12:12:01 -05:00
renovate[bot]
ad3f4fb434 chore(deps): update dependency validator to v13.15.20 [security] (#23284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 17:37:51 +01:00
Juan Roa
8001dedcbf fix(web): keep album timeline when selecting cover (#23819) 2025-11-13 16:30:24 +00:00
Hritik V
07a39226c5 chore: include link to discord server when referencing contribution channel (#23728)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-13 16:27:26 +00:00
shenlong
88e7e21683 fix: prefer filename from body over path in mime validation (#23810)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-13 17:26:34 +01:00
idubnori
2cefbf8ca3 chore: add "pnpm run migrations:revert" command (#23869) 2025-11-13 10:12:59 -06:00
Jason Rasmussen
4a6c50cd81 feat: endpoint versioning (#23858) 2025-11-13 08:18:43 -05:00
Daniel Dietzler
e0535e20e6 chore: cleanup web (#23866) 2025-11-13 12:51:17 +00:00
bo0tzz
62580455af fix: use changelog file instead of PR body (#23864) 2025-11-13 10:35:30 +00:00
Daniel Dietzler
0e7e67efe1 fix: timeline scroll after navigate (#23664) 2025-11-13 11:28:42 +01:00
Sergey Katsubo
2c54b506b3 fix(server): include the previous year in memories for January 1, 2, 3 (#23832)
* Test memory creation in advance

Use year 2035 to make sure it's in the future of current time of a test run

* Use target year instead of current year when fetching assets during memory creation

This fixes an edge case of creating memories in advance when target year is
different from current year.
Example: job runs on 2025-12-31 (current year is 2025) and creates memories
to be shown on 2026-01-01 (target year is 2026). If using _current_ year in
calculation then range of years is capped at (2025 - 1 = 2024) thus excluding
2025-01-01 from created memories. With _target_ year it is (2026 - 1 = 2025),
so 2025-01-01 will be included in memories.

* Update sql queries
2025-11-12 15:38:03 -06:00
Alex
8969b8bdb2 fix: GHA build issue on iOS (#23849)
* fix: GHA build issue on iOS

* fix: resolve Swift Package dependencies in GitHub Actions

* fix: use Release configuration for iOS build

* fix: simplify code signing for build-only lane

* fix: explicitly resolve Swift packages before building

* fix: use specified XCode version
2025-11-12 15:32:08 -06:00
Alexander Sulfrian
5186092faa fix: Update module name for rapidocr DownloadFile (#23838) 2025-11-12 18:43:00 +00:00
bo0tzz
4c9142308f fix: use app token for github-script run (#23852) 2025-11-12 19:16:09 +01:00
bo0tzz
bea5d4fd37 fix: release-pr workflow fixes (#23850) 2025-11-12 18:25:32 +01:00
bo0tzz
74c24bfa88 fix: pump-version.sh flags (#23848) 2025-11-12 17:47:52 +01:00
bo0tzz
95834c68d9 fix: bump args order (#23846) 2025-11-12 17:31:25 +01:00
bo0tzz
09024c3558 fix: release-pr script name (#23844) 2025-11-12 16:24:39 +00:00
bo0tzz
137cb043ef feat: track next release in pull request (#23806) 2025-11-12 17:19:18 +01:00
Mees Frensel
edf21bae41 feat(web): disable searching by disabled features (#23798)
fix(web): disable searching by disabled features
2025-11-12 09:19:18 -06:00
shenlong
c958f9856d chore: bump background_downloader (#23839)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-12 09:17:44 -06:00
Daniel Dietzler
70ab8bc657 refactor: shared links modals (#23803) 2025-11-12 12:57:53 +01:00
Jason Rasmussen
edde0f93ae feat: endpoint descriptions (#23813) 2025-11-11 17:01:14 -05:00
Alex
896665bca9 fix: iOS release build dependency verification (#23814) 2025-11-11 15:35:44 -06:00
renovate[bot]
e8e9e7830e chore(deps): update github-actions (major) (#23812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-11-11 20:04:22 +00:00
Mees Frensel
4fd9e42ce5 feat(web): animate gifs on hover (#23198) 2025-11-11 10:22:53 -06:00
idubnori
337e3a8dac feat(mobile): album activity deep link (#23737)
* feat: add activity deep link support in DeepLinkService

* test: add unit tests for DeepLinkService handling of activity deep links

* Revert "test: add unit tests for DeepLinkService handling of activity deep links"

This reverts commit 0b1914be9a.
2025-11-11 10:04:54 -06:00
renovate[bot]
2dc81e28fc chore(deps): update github-actions (#23582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 16:25:36 +01:00
bo0tzz
f915d4cc90 fix: disable ruby updates (#23794)
Until https://github.com/fastlane/fastlane/issues/29183 is fixed
2025-11-11 14:51:21 +00:00
Mees Frensel
905f4375b0 fix(web): make sliding window cover all visible space to show small number of assets (#23796) 2025-11-11 08:50:31 -06:00
David Wolff
0b3633db4f fix(server): properly handle HEAD requests to SSR paths (#23788) 2025-11-11 07:47:11 -05:00
Jason Rasmussen
2f40f5aad8 refactor: user admin service (#23785) 2025-11-11 07:42:33 -05:00
renovate[bot]
2611e2ec20 chore(deps): update dependency exiftool-vendored to v31.3.0 (#23787)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 13:35:36 +01:00
renovate[bot]
433a3cd339 chore(deps): update dependency @types/node to ^22.19.0 (#23786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 23:50:50 -05:00
Jason Rasmussen
0b487897a4 refactor: shared link service (#23775) 2025-11-10 16:17:18 -05:00
Jason Rasmussen
d5c5bdffcb refactor: album delete (#23773) 2025-11-10 16:10:29 -05:00
Jason Rasmussen
dea95ac2e6 refactor: shared-link service (#23770) 2025-11-10 20:49:02 +00:00
Mert
9e2208b8dd chore(mobile): add table schemas to swift (#23749)
* add schemas

* handle json, improve type safety

* formatting

* sync variants

* formatting
2025-11-10 20:21:08 +00:00
Alex
6922a92b69 feat: show update version info (#23698)
* feat: show update version info

* Apply suggestions from code review

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

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-10 14:19:27 -06:00
exelix
7a2c8e0662 feat(mobile): Quick date picker in the search page (#22653)
* Quick date picker

* Include current year in quick date picker

* Quick date picker: localization, fix datetime overflows

* Properly localized 'last_months'

* Move quick_date_picker.dart to lib/presentation/widgets/search

* Wrap the quick date picker state into its own class, improve the interaction patterns

* Fix last9Months value

* Improve method naming

* Subtitle for "custom range" in quick date picker

* Fix style warnings

* Fix lint warning

* fix:  mobile unawaited_futures lint (#21661)

* chore: add unawaited_futures lint as warning

* remove unused dcm lints

They will be added back later on a case by case basis

* fix warning

* auto gen file

* review changes

* conflict resolution

---------

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

* Quick date picker

* Wrap the quick date picker state into its own class, improve the interaction patterns

* chore: delete file from rebase

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: bwees <brandonwees@gmail.com>
2025-11-10 13:55:09 -06:00
renovate[bot]
787158247f fix(deps): update typescript-projects (#23588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-10 19:50:19 +00:00
idubnori
b0a0b7c2e1 feat(mobile): chat-style for asset activity view (#23347)
* feat(mobile): open assetviewer via album activities page

* adjust ui behavior: keep current asset & disable initial forcus

* init of v2...

* refactoring...

* refactor: remove _DismissibleWrapper

* feat: initial scrolling to bottom

* refactor: use feature toggle

* refactor: new route page

* fix: file name, dcm analyze

* fix: test failure

* fix: remove toggle and the exisitng style based on review feedback

* refactorr: rename methods for clarity in comment bubble widget

* feat: (mobile) chat-style asset activity timeline

* chore: extract as a new file

* chore: styling (based on 2c12bc56)

* chore: clean up

* fix: albumActivityProvider parameter

* fix: review point

* fix
2025-11-10 13:26:27 -06:00
idubnori
cb6d81771d fix(mobile): sync album and asset activity state when add/remove asset level activity (#23484)
* fix; sync album-asset state when remove activity

* make build

* fix: support adding case

* make build

* Update mobile/lib/providers/activity.provider.dart

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

* fix: add missing import for collection package

* make build

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-11-10 13:25:43 -06:00
Jason Rasmussen
8de6ec1a1b refactor: album service (#23768) 2025-11-10 13:40:58 -05:00
Daniel Dietzler
d27c01ef70 chore: migrate remaining usages of the logo to use the UI lib (#23430) 2025-11-10 19:16:49 +01:00
Noel S
d6307b262f fix(mobile): Hide download button in asset viewer "immersive mode" (#23720)
* Hide download FAB in asset viewer immersive mode

* Remove commented out code

* Remove more comments
2025-11-10 12:13:04 -06:00
Viktor Mykhailiv
b2cbefe41e fix(mobile): Set dynamic height of actions row in BottomSheet (#23755)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-10 12:03:12 -06:00
shenlong
da5a72f6de chore: patch MemoriesResponse (#23764)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-10 11:37:45 -06:00
Jason Rasmussen
45304f1211 refactor: view shared link (#23766) 2025-11-10 12:21:26 -05:00
Jason Rasmussen
a4e65a7ea8 refactor: albums-list (#23765) 2025-11-10 11:49:59 -05:00
Daniel Dietzler
dd393c8346 feat(web): event handler component (#23763) 2025-11-10 11:49:46 -05:00
Peter Ombodi
493cde9d55 feat: opt-in sync of deletes and restores from web to Android (beta timeline) (#20473)
* feature(mobile, beta, Android): handle remote asset trash/restore events and rescan media
- Handle move to trash and restore from trash for remote assets on Android
- Trigger MediaScannerConnection to rescan affected media files

* feature(mobile, beta, Android): fix rescan

* fix imports

* fix checking conditions

* refactor naming

* fix line breaks

* refactor code
rollback changes in BackgroundServicePlugin

* refactor code (use separate TrashService)

* refactor code

* parallelize restoreFromTrash calls with Future.wait
format trash.provider.dart

* try to re-format trash.provider.dart

* re-format trash.provider.dart

* rename TrashService to TrashSyncService to avoid duplicated names
revert changes in original trash.provider.dart

* refactor code (minor nitpicks)

* process restoreFromTrash sequentially instead of Future.wait

* group local assets by checksum before moving to trash
delete LocalAssetEntity records when moved to trash
refactor code

* fix format

* use checksum for asset restoration
refactro code

* fix format

* sync trash only for backup-selected assets

* feat(db): add local_trashed_asset table and integrate with restoration flow
- Add new `local_trashed_asset` table to store metadata of trashed assets
- Save trashed asset info into `local_trashed_asset` before deletion
- Use `local_trashed_asset` as source for asset restoration
- Implement file restoration by `mediaId`

* resolve merge conflicts

* fix index creating on migration

* rework trashed assets handling
- add new table trashed_local_asset
- mirror trashed assets data in trashed_local_asset.
- compute checksums for assets trashed out-of-app.
- restore assets present in trashed_local_asset and non-trashed in remote_asset.
- simplify moving-to-trash logic based on remote_asset events.

* resolve merge conflicts
use updated approach for calculating checksums

* use CurrentPlatform instead _platform
fix mocks

* revert redundant changes

* Include trashed items in getMediaChanges
Process trashed items delta during incremental sync

* fix merge conflicts

* fix format

* trashed_local_asset table mirror of local_asset table structure
trashed_local_asset<->local_asset transfer data on move to trash or restore
refactor code

* refactor and format code

* refactor TrashedAsset model
fix missed data transfering

* refactor code
remove unused model

* fix label

* fix merge conflicts

* optimize, refactor code
remove redundant code and checking
getTrashedAssetsForAlbum for iOS
tests for hash trashed assets

* format code

* fix migration
fix tests

* fix generated file

* reuse exist checksums on trash data update
handle restoration errors
fix import

* format code

* sync_stream.service depend on repos
refactor assets restoration
update dependencies in tests

* remove trashed asset model
remove trash_sync.service
refactor DriftTrashedLocalAssetRepository, LocalSyncService

* rework fetching trashed assets data on native side
optimize handling trashed assets in local sync service
refactor code

* update NativeSyncApi on iOS side
remove unused code

* optimize sync trashed assets call in full sync mode
refactor code

* fix format

* remove albumIds from getTrashedAssets params
fix upsert in trashed local asset repo
refactor code

* fix getTrashedAssets params

* fix(trash-sync): clean up NativeSyncApiImplBase and correct applyDelta

* refactor(trash-sync): optimize performance and fix minor issues

* refactor(trash-sync): add missed index

* feat(trash-sync): remove sinceLastCheckpoint param from getTrashedAssets

* fix(trash-sync): fix target table

* fix(trash-sync): remove unused extension

* fix(trash-sync): remove unused code

* fix(trash-sync): refactor code

* fix(trash-sync): reformat file

* fix(trash_sync): refactor code

* fix(trash_sync): improve moving to trash

* refactor(trash_sync): integrate MANAGE_MEDIA permission request into login flow and advanced settings

* refactor(trash_sync): add additional checking for experimental trash sync flag and MANAGE_MEDIA permission.

* refactor(trash_sync): resolve merge conflicts

* refactor(trash_sync): fix format

* resolve merge conflicts
add await for alert dialog
add missed request

* refactor(trash_sync): rework MANAGE_MEDIA info widget
show rationale text in permission request alert dialog
refactor setting getter

* fix(trash_sync): restore missing text values

* fix(trash_sync): format file

* fix(trash_sync): check backup enabled and remove remote asset existence check

* fix(trash_sync): remove checking backup enabled
test(trash_sync): cover sync-stream trash/restore paths and dedupe mocks

* test(trash_sync): cover trash/restore flows for local_sync_service

* chore(e2e): restore test-assets submodule pointer

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-10 16:20:51 +00:00
Jason Rasmussen
7705c84b04 refactor(web): album service (#23762) 2025-11-10 11:06:40 -05:00
Matthew Momjian
ce0172b8c1 fix(docs): bump docs for PG versions (#23714) 2025-11-10 10:56:18 -05:00
bo0tzz
718b3a7b52 fix: mise tf task scope (#23761) 2025-11-10 15:49:44 +00:00
bo0tzz
8a73de018c feat: mise monorepo tasks (#23691) 2025-11-10 15:55:15 +01:00
Jonathan Gilbert
d92df63f84 feat: random memories sort order (#20025) 2025-11-10 09:38:50 -05:00
Mees Frensel
6c6b00067b fix(web): i18n for admin>users>sessions (#23756) 2025-11-10 12:48:17 +00:00
Mees Frensel
9cc88ed2a6 feat: make memories slideshow duration configurable (#22783) 2025-11-08 17:46:43 -05:00
renovate[bot]
4905bba694 chore(deps): update base-image to v202511041104 (major) (#23718)
chore(deps): update base-image to v202511041104

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:48:35 -06:00
Noel S
853d19dc2d fix(mobile): Add fade-in to asset viewer transition (#23692)
Add fade-in animation
2025-11-07 17:13:43 -06:00
Lukas
c935ae47d0 feat: lazy load thumbnails on people and place list (#23682)
perf(web): lazy load thumbnails on people and place list
2025-11-07 14:22:02 -06:00
fabianbees
93ab42fa24 feat(mobile): Show lens model information in the asset viewer detail panel (#23601)
* feat(mobile): add lens info to details bottom sheet

* fix unrelated typo

* order same like in web app: first exposure time, than iso
2025-11-07 17:10:59 +00:00
Mert
6913697ad1 feat(ml): multilingual ocr (#23527)
* handle other languages in ml server

* add variants to model selector

* no need to override path

* unused import
2025-11-06 12:58:41 -05:00
Mert
a4ae86ce29 feat(ml): add preload and fp16 settings for ocr (#23576) 2025-11-06 17:55:11 +00:00
Snowknight26
2c50f2e244 fix(web): add URLs to results in large files utility (#23617)
fix(web): add URLs to results in large files
2025-11-06 09:24:47 -05:00
shenlong
365abd8906 fix: check if unmetered instead of wifi (#23380)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-05 13:27:38 -06:00
Alex
25fb43bbe3 fix: fully sync local library on app restart (#23323) 2025-11-05 14:09:50 +00:00
bo0tzz
125e8cee01 chore: update config.json example (#23471)
* chore: update config.json example

closes #23465

* fix: format, for real this time
2025-11-05 08:05:53 -06:00
Arnaud Wery
c15e9bfa72 fix(web): "select all" button in trash and permanently deleted count (#23594) 2025-11-05 14:05:48 +00:00
Dmitry
35e188e6e7 docs: sync ru docs with main README.md (#23627) 2025-11-05 08:05:03 -06:00
Sergey Katsubo
3cc9dd126c fix(web): fix timezone dropdown for timestamps lacking milliseconds (#23615)
Fix timezone selector for timestamps without milliseconds
2025-11-05 08:03:55 -06:00
Jason Rasmussen
aa69d89b9f fix: bad merge (#23610) 2025-11-04 16:22:45 -05:00
Jason Rasmussen
29c14a3f58 refactor: database column names (#23356) 2025-11-04 16:03:21 -05:00
Jason Rasmussen
0df70365d7 feat: exif medium tests (#23561) 2025-11-04 16:03:02 -05:00
Mees Frensel
c34be73d81 fix(web): consistently use mdiMotionPauseOutline icon (#23595) 2025-11-04 12:12:47 +01:00
renovate[bot]
f396e9e374 chore(deps): update prom/prometheus docker digest to 4921475 (#23578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:49:12 +01:00
renovate[bot]
821a9d4691 chore(deps): update redis:6.2-alpine docker digest to 37e0024 (#23579)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:48:21 +01:00
renovate[bot]
cad654586f chore(deps): update dependency @types/node to ^22.18.13 (#23581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:47:54 +01:00
github-actions
28eb1bc13c chore: version v2.2.3 2025-11-04 03:14:34 +00:00
Brandon Wees
1e4779cf48 fix(mobile): ignore patch releases for app version alerts (#23565)
* fix(mobile): ignore patch releases for app version alerts

* chore: make difference type nullable to indicate when versions match

* chore: add error handling for semver parsing

* chore: tests
2025-11-03 21:09:32 -06:00
Sergey Katsubo
0647c22956 fix(mobile): handle empty original filename (#23469)
* Handle empty original filename

* Handle TypeError from photo_manager titleAsync

* More compact exception log
2025-11-03 21:09:18 -06:00
Alex
b8087b4fa2 chore: ios prod build with correct argument, get version number from pubspec (#23554)
* chore: ios prod build with correct argument, get version number from pubspec

* Update mobile/ios/fastlane/Fastfile

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

---------

Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-11-03 10:11:11 -06:00
Jonathan S
d94cb9641b chore: correct hosted isar paths in fdroid_build_isar.sh (#23529)
This should hopefully unblock F-Droid builds, which are a few versions behind.

Based on the suggestion in https://github.com/immich-app/immich/pull/22757#issuecomment-3404516987
2025-11-03 08:35:56 -06:00
Daniel Dietzler
517c3e1d4c fix: exif gps parsing of malformed data (#23551)
* fix: exif gps parsing of malformed data

* chore: e2e test
2025-11-03 09:02:41 -05:00
Ben
619de2a5e4 fix(web): search bar accessibility (#23550)
* fix: always show search type when search bar is focused

* fix: indicate search type to screen reader users
2025-11-03 08:31:57 -05:00
Mert
79d0e3e1ed fix(ml): ocr inputs not resized correctly (#23541)
* fix resizing, use pillow

* unused import

* linting

* lanczos

* optimizations

fused operations

unused import
2025-11-03 07:21:30 +00:00
github-actions
f5ff36a1f8 chore: version v2.2.2 2025-11-02 21:56:36 +00:00
Alex
b5efc9c16e fix: passing secrets to trigger workflow (#23447)
* fix: passing secrets to trigger workflow

* pass secrets to workflow call
2025-11-02 15:54:35 -06:00
Alex
1036076b0d fix: disable prunning for more investigation (#23531) 2025-11-02 15:54:03 -06:00
Daniel Dietzler
c76324c611 fix(web): mobile scrubber on page load (#23488) 2025-11-01 22:15:33 -05:00
bo0tzz
0ddb92e1ec fix: use pnpm directly for fix-format (#23483) 2025-11-01 15:38:18 -04:00
Alex
d08a520aa2 chore: post release tasks (#23443) 2025-11-01 01:21:39 -05:00
dotlambda
7bdf0f6c50 chore(ml): remove setuptools from dependencies (#23446) 2025-10-31 21:34:10 +00:00
shenlong
2b33a58448 fix: show in timeline from search page (#23440)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-31 14:55:28 -05:00
github-actions
b35f00f768 chore: version v2.2.1 2025-10-31 18:04:27 +00:00
Weblate (bot)
86cc7c3c73 chore(web): update translations (#23375)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/
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/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/
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/ru/
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/tr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translation: Immich/immich

Co-authored-by: Akhil Raj Baranwal <akhil.r.baranwal@gmail.com>
Co-authored-by: Dennis Kjær Jensen <weblate@signout.dk>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Florian Amsallem <florian.amsallem@gmail.com>
Co-authored-by: Hurricane-32 <rodrigorimo@hotmail.com>
Co-authored-by: Kai Heine <kai-heine@users.noreply.hosted.weblate.org>
Co-authored-by: Marrick Schröder <marrick.schroeder@gmail.com>
Co-authored-by: Michael <parieren.gefuehl5g@icloud.com>
Co-authored-by: PontusÖsterlindh <pontus@osterlindh.com>
Co-authored-by: S M, Aravinth (A.) <asm1@ford.com>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Vegard Fladby <vegard@fladby.org>
Co-authored-by: linux-universe <lauro@dilorenzo.one>
Co-authored-by: shiuh67 <shiuh.cheng@gmail.com>
Co-authored-by: slick-daddy <129640104+slick-daddy@users.noreply.github.com>
Co-authored-by: ti-guru <anders.egeland@outlook.com>
2025-10-31 18:02:30 +00:00
Alex
5854cbbe97 fix: show close button on purchase modal (#23436) 2025-10-31 17:47:14 +00:00
Alex
ceb36a304d fix: view in timeline does not jump to the timeline correctly (#23428) 2025-10-31 17:24:41 +00:00
Daniel Dietzler
f5d7e5acca chore: cannonical tailwind classes (#23427) 2025-10-31 11:38:17 -04:00
luneth
be15a84f9b chore: update android signing fingerprints to docs (#23361)
* Update mobile-app.mdx

Add certificate fingerprint for android releases.

* chore: formatting

* Chore: Typo

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-10-31 09:40:53 -05:00
Alex
32791e98c2 chore: trigger prod build on prepare-release (#23424)
* chore: trigger prod build on prepare-release

* clean up
2025-10-31 14:26:03 +00:00
Alex
7ea443b3a9 chore: gha ios release | take 5 (#23203)
* chore: gha ios release | take 5

* code signing

* code signing 2

* manual signing for extensions

* chore(ios): add explicit code signing identity and debug output

* dev appbundle

* Deployment flow for development app

* skip waiting for change log

* refactor

* fix: ruby version

* fix: manual release lane

* build on main
2025-10-31 09:05:03 -05:00
Alex
c69786b039 fix: button condition rendering (#23400) 2025-10-31 08:42:01 -05:00
Mert
5c7d5539ea fix(mobile): video seeking on android (#23405)
use int for seeking
2025-10-31 08:41:09 -05:00
Daniel Dietzler
3531856d1c refactor: api key modals (#23420) 2025-10-31 08:58:52 -04:00
Mert
4abaad548a fix(ml): ocr failing with rootless docker (#23402)
don't download font
2025-10-31 02:41:49 -04:00
Jonathan Jogenfors
61a2c3ace3 chore(server): clarify asset copy parameters (#23396) 2025-10-30 23:55:39 +00:00
Daniel Dietzler
e9038193db fix: asset copy validation error (#23387) 2025-10-30 19:40:58 -04:00
bo0tzz
3f5cd48a59 fix: don't use app token for cli push (#23378) 2025-10-30 21:31:56 +01:00
idubnori
4cb094e7ae fix(mobile): regression - not displayed activity button in top bar (#23366) 2025-10-30 14:39:36 -05:00
github-actions
57c8378ca7 chore: version v2.2.0 2025-10-30 14:42:44 +00:00
Weblate (bot)
b073f9b802 chore(web): update translations (#22937)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/
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/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/el/
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/eu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fa/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fil/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/
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/ko/
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/mk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/mr/
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/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/ro/
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/sr_Cyrl/
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/uk/
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: 0xflotus <0xflotus@gmail.com>
Co-authored-by: Aarón Rosa Díaz <sraaronrock@tuta.io>
Co-authored-by: Abhijeet Bonde <abhijeetbonde19@gmail.com>
Co-authored-by: Abraham Escalona <abrahamescalona@live.com>
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
Co-authored-by: Alberto Serluca <alberto.ser11@gmail.com>
Co-authored-by: Alejandro Gonzalez <alejandrok5@gmail.com>
Co-authored-by: Alejandro Moya <alejandro_moya_moya@hotmail.com>
Co-authored-by: Alex <alex.osheter@gmail.com>
Co-authored-by: Alexis-Loskoutoff <alexis@pctraining.fr>
Co-authored-by: AndreiP28 <andreiprica28@gmail.com>
Co-authored-by: Antonio <1628876+antonio-ivanovski@users.noreply.github.com>
Co-authored-by: Beans <leey0818@gmail.com>
Co-authored-by: Benjamin GOUPIL <benjamin@goupil.bzh>
Co-authored-by: Blomblo <mr.blomblo@gmail.com>
Co-authored-by: Cyril CHARLIER <cyril.charlier@gmail.com>
Co-authored-by: Davide Vegliante <davidevegliante@gmail.com>
Co-authored-by: Denis Pacquier <denis.pacquier@gmail.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Devansh Sehgal <devanshsehgal02@gmail.com>
Co-authored-by: Diego Heras <ngosang@hotmail.es>
Co-authored-by: Durneztj <thibault.durnez@telenet.be>
Co-authored-by: Eetu Mäenpää <me@eetumaenpaa.fi>
Co-authored-by: Emil Friis Osmann <Emilfriisosmann@gmail.com>
Co-authored-by: Espen Faale <espen@faale.no>
Co-authored-by: Flyingfufu <fabien.fuster@icloud.com>
Co-authored-by: Frederick “Fredyy” Behrends <frederick.behrends@gmail.com>
Co-authored-by: Hamed Hojjati <hamed334@gmail.com>
Co-authored-by: Hurricane-32 <rodrigorimo@hotmail.com>
Co-authored-by: Ignatius Liu <suitangi777@gmail.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: Jacob Zhang <jacob-z@live.de>
Co-authored-by: Jason Song <songpeiheng@gmail.com>
Co-authored-by: Jasper van der Neut - Stulen <jasper@neutstulen.nl>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Jirapan <jirapan_yankhan@hotmail.com>
Co-authored-by: Jonas A <demo007@gmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Leo Bottaro <github@leobottaro.com>
Co-authored-by: Liviu Roman <contact@liviuroman.com>
Co-authored-by: Lotzi <info@lorenzmueller.de>
Co-authored-by: Lukas Konsin <lukaskonsin@proton.me>
Co-authored-by: MSDNicrosoft <wang3311835119@hotmail.com>
Co-authored-by: Marcelo Popper Costa <marcelo_popper@hotmail.com>
Co-authored-by: Mario Carlotti <info@carlotti.ch>
Co-authored-by: Marko Stanković <stankovic.marko@gmail.com>
Co-authored-by: Martin Piron <martin.piron@hotmail.com>
Co-authored-by: Matjaž T <matjaz@moj-svet.si>
Co-authored-by: Mees Frensel <meesfrensel@gmail.com>
Co-authored-by: Mehmet MENENGEÇ <mehmetmenengec+weblate@gmail.com>
Co-authored-by: Metin <durmus38metin@gmail.com>
Co-authored-by: Mohsin Bouhout <bouhout.mohsin@gmail.com>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Nicolai Bonde <git@nicolaibonde.dk>
Co-authored-by: Ole Steinbrück <ole.steinbrueck@googlemail.com>
Co-authored-by: Paolo Forte <paoloforte71@gmail.com>
Co-authored-by: Passawish Paktiwong <passawishp@outlook.com>
Co-authored-by: Pedro Vendeira <vendeira.pedro@gmail.com>
Co-authored-by: Peter Dave Hello <hsu@peterdavehello.org>
Co-authored-by: Petri Hämäläinen <petri.hamalainen@mailbox.org>
Co-authored-by: PontusÖsterlindh <pontus@osterlindh.com>
Co-authored-by: Rasmus Sehlin <rasmus@sehl.in>
Co-authored-by: Robert Gonzales <bgonz808@gmail.com>
Co-authored-by: Ron Turner <admin@meetronturner.com>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Stan P <g97d6liib@mozmail.com>
Co-authored-by: Steven Barash <stevenbarash6@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TMM4MN <tmgweb@yahoo.com>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: Tage Lauritsen <tage@tunenet.dk>
Co-authored-by: Theofilos Nikolaou <th.nikolaou@gmail.com>
Co-authored-by: Tjibbe Chris <github@tjibbechris.nl>
Co-authored-by: Tmpod <tom@tmpod.dev>
Co-authored-by: Tom Kay <kowalzik@proton.me>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Valentino Harpa <valen.ginga@gmail.com>
Co-authored-by: Vegard Fladby <vegard@fladby.org>
Co-authored-by: Zsombor L <lzso1.lzso1@gmail.com>
Co-authored-by: anton garcias <isaga.percompartir@gmail.com>
Co-authored-by: aouani jaessin <aouanijaessin@gmail.com>
Co-authored-by: basti n00b0ss <n00b0ss@mailbox.org>
Co-authored-by: bilal-khendaf <bilalkhendaf@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: chamdim <chamdim@protonmail.com>
Co-authored-by: intothevolt <francesco.ferriero97@gmail.com>
Co-authored-by: khaled maayeh <maayehkhaled@gmail.com>
Co-authored-by: kiwinho <kiwicaja@gmail.com>
Co-authored-by: millallo <millallo@tiscali.it>
Co-authored-by: mkubant <marek@kubantovi.cz>
Co-authored-by: nachtpfoetchen <nachtpfoetchen@posteo.de>
Co-authored-by: om1s186 <om1s186@gmail.com>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: rw-r-r-0644 <rw-r-r-0644@proton.me>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: vytautas <immichtranslation.a03gn@simplelogin.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Àlex Bravo <alexbravobosch@gmail.com>
Co-authored-by: özcan karakuş <ozcan.krakus@gmail.com>
Co-authored-by: Александр Стельмах <aguhadug@gmail.com>
Co-authored-by: Вячеслав Лукьяненко <madeinchuguev@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2025-10-30 14:40:49 +00:00
Alex
1a2e7d06cb chore: make view similar photo button more discoverable (#23350) 2025-10-29 15:38:50 +00:00
Alex
217d719b0b chore: re-enable android build (#23349) 2025-10-29 10:22:07 -05:00
shenlong
cf75ad2f26 fix: prune stale assets (#22530)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-10-29 14:41:03 +00:00
Alex
2286444158 chore: css nits (#23330) 2025-10-29 09:20:04 -05:00
renovate[bot]
b489bdf8d3 chore(deps): update node.js to v24 (#23346)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 14:48:20 +01:00
idubnori
5e6087ea28 feat(mobile): chat-style album activities timeline (#23185)
* feat(mobile): open assetviewer via album activities page

* adjust ui behavior: keep current asset & disable initial forcus

* init of v2...

* refactoring...

* refactor: remove _DismissibleWrapper

* feat: initial scrolling to bottom

* refactor: use feature toggle

* refactor: new route page

* fix: file name, dcm analyze

* fix: test failure

* fix: remove toggle and the exisitng style based on review feedback

* refactorr: rename methods for clarity in comment bubble widget

* chore: styling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-10-29 13:45:28 +00:00
Daniel Dietzler
4ae7cadeae feat: asset copy (#23172) 2025-10-29 08:43:47 -05:00
renovate[bot]
fdfb04d83c fix(deps): update typescript-projects (#23311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-10-29 11:34:20 +00:00
renovate[bot]
8273c822d7 chore(deps): pin dependencies (#23304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 20:27:54 -05:00
Viktor Mykhailiv
12bb39a111 feat(mobile): view similar photos (#22148)
* feat: view similar photos on mobile

# Conflicts:
#	mobile/lib/models/search/search_filter.model.dart
#	mobile/lib/utils/action_button.utils.dart

* fix: bottom sheet is unusable after navigating to search

* feat(mobile): open DriftSearchPage as root route

* reset search state on tab navigation

* fix tests

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-28 16:17:26 -05:00
Alex
9098717c55 feat: getAssetOcr endpoint (#23331)
* feat: getAssetOcr endpoint

* pr feedback
2025-10-28 20:57:03 +00:00
Min Idzelis
8d25f81bec fix: regression - search results not visible until scroll (#23321) 2025-10-28 14:15:24 -05:00
Jason Rasmussen
52596255c8 feat: toasts (#23298) 2025-10-28 14:09:11 -05:00
Alex
106effca2e feat: check server feature to render OCR search option (#23325) 2025-10-28 13:54:41 -05:00
shenlong
9676da27c9 fix: clear temp cache on iOS before uploads (#23326)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-28 13:53:48 -05:00
Brandon Wees
3edcb180eb fix: flaky mobile sync api tests (#23324) 2025-10-28 12:16:36 -05:00
renovate[bot]
9f0b5790af chore(deps): update dependency @types/node to ^22.18.12 (#23305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 18:16:18 +01:00
Brandon Wees
e0c2cdddd4 feat: show "appears in" albums on asset viewer bottom sheet (#21925)
* feat: show "appears in" albums on asset viewer bottom sheet

fix: multiple RemoteAlbumPages in navigation stack

this also allows us to not have to set the current album before navigating to RemoteAlbumPage

chore: clarification comments

handle nested album pages

fix: hide "appears in" when an asset is not in any albums

fix: way more bottom padding

for some reason we can't query the safe area here :/

* fix: bottom sheet now is usable when navigating to another asset viewer

* fix: rebase conflict

* fix: restore ancestors album to currentRemoteAlbumProvider when popping

* fix: view flashing when dismissing a album viewer

* chore: code review changes

* fix: styling and padding

* chore: rework currentRemoteAlbumProvider to be scoped by the Remote album page

* fix: override remote album provider on required pages

* chore: convert query to all SQL calls instead of matching in Dart

* fix: album query

* fix: unawaited future

* Update deep_link.service.dart

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-10-28 16:52:01 +00:00
Brandon Wees
74f2c10a5a fix: make hitbox on app bar dialog bigger (#23316) 2025-10-28 09:19:40 -05:00
Thomas Stachl
fb97d9f4d9 fix(web): disable picture-in-picture on video viewer (#23318) 2025-10-28 09:15:35 -05:00
renovate[bot]
f72bcc8a8f chore(deps): update node.js to v22.21.0 (#23314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 12:36:19 +00:00
renovate[bot]
46a4dce16b chore(deps): update grafana/grafana docker tag to v12.2.1 (#23312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 12:32:37 +00:00
renovate[bot]
62ed5fe27f chore(deps): update base-image to v202510281104 (major) (#23315)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 12:30:42 +00:00
Zack Pollard
8e3f6cdbbf fix: ml container tags incorrect for different hardware builds (#23313)
Co-authored-by: bo0tzz <git@bo0tzz.me>
2025-10-28 12:30:12 +00:00
Min Idzelis
d51b8c1cdf fix: focus-trap on safari (#23246) 2025-10-27 21:29:30 -05:00
Alex
698531d6e0 feat: improve UI for resolving duplication detection (#23145)
* feat: improve UI for resolving duplication detection

* pr feedback
2025-10-27 17:32:52 -04:00
Thomas Stachl
44149d187f feat(server): enhance metadata reading for video files (#23258) 2025-10-27 14:46:54 -05:00
shenlong
9e3b4ef3db chore(dep): bump flutter to 3.35.7 (#23287)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-27 10:24:41 -05:00
shenlong
ac0d646401 fix: mobile unawaited_futures lint (#21661)
* chore: add unawaited_futures lint as warning

# Conflicts:
#	mobile/analysis_options.yaml

* remove unused dcm lints

They will be added back later on a case by case basis

* fix warning

# Conflicts:
#	mobile/lib/presentation/pages/drift_remote_album.page.dart

* auto gen file

* review changes

* conflict resolution

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-27 09:32:52 -05:00
shenlong
664a8fa499 fix: fetch original name before upload (#21877)
* fix: fetch origin name before upload

* fix: Show correct photo name in buttom sheet and backup details page (#22978)

* add tests

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: FawenYo <40032648+FawenYo@users.noreply.github.com>
2025-10-27 09:32:24 -05:00
shenlong
3194538817 fix: handle null bucketId or name in android local sync (#23224)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-27 09:22:51 -05:00
Zac Warham
b0d427f8f9 chore: show leading zero week in storage template (#23275)
* Use date which shows week with a zero

* Update sample date in SupportedDatetimePanel

* Update web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-10-27 09:21:37 -05:00
Kang
02b29046b3 feat: ocr (#18836)
* feat: add OCR functionality and related configurations

* chore: update labeler configuration for machine learning files

* feat(i18n): enhance OCR model descriptions and add orientation classification and unwarping features

* chore: update Dockerfile to include ccache for improved build performance

* feat(ocr): enhance OCR model configuration with orientation classification and unwarping options, update PaddleOCR integration, and improve response structure

* refactor(ocr): remove OCR_CLEANUP job from enum and type definitions

* refactor(ocr): remove obsolete OCR entity and migration files, and update asset job status and schema to accommodate new OCR table structure

* refactor(ocr): update OCR schema and response structure to use individual coordinates instead of bounding box, and adjust related service and repository files

* feat: enhance OCR configuration and functionality

- Updated OCR settings to include minimum detection box score, minimum detection score, and minimum recognition score.
- Refactored PaddleOCRecognizer to utilize new scoring parameters.
- Introduced new database tables for asset OCR data and search functionality.
- Modified related services and repositories to support the new OCR features.
- Updated translations for improved clarity in settings UI.

* sql changes

* use rapidocr

* change dto

* update web

* update lock

* update api

* store positions as normalized floats

* match column order in db

* update admin ui settings descriptions

fix max resolution key

set min threshold to 0.1

fix bind

* apply config correctly, adjust defaults

* unnecessary model type

* unnecessary sources

* fix(ocr): switch RapidOCR lang type from LangDet to LangRec

* fix(ocr): expose lang_type (LangRec.CH) and font_path on OcrOptions for RapidOCR

* fix(ocr): make OCR text search case- and accent-insensitive using ILIKE + unaccent

* fix(ocr): add OCR search fields

* fix: Add OCR database migration and update ML prediction logic.

* trigrams are already case insensitive

* add tests

* format

* update migrations

* wrong uuid function

* linting

* maybe fix medium tests

* formatting

* fix weblate check

* openapi

* sql

* minor fixes

* maybe fix medium tests part 2

* passing medium tests

* format web

* readd sql

* format dart

* disabled in e2e

* chore: translation ordering

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-10-27 14:09:55 +00:00
Min Idzelis
c666dc6c67 fix: back/forward navigation won't reset scroll in timeline (#22838)
* fix: back/forward navigation won't reset scroll in timeline

Fixes a bug where navigating to/from the asser-viewer from timeline causes the scroll position to be reset.

* Fix back after assetviewer next/prev navigation

* Bug fix from review

* review comments
2025-10-27 08:56:03 -05:00
Jorge Montejo
382481735a feat: logout sessions on password change (#23188)
* log out ohter sessions on password change

* translations

* update and add tests

* rename event to UserLogoutOtherSessions

* fix typo

* requested changes

* fix tests

* fix medium:test

* use ValidateBoolean

* fix format

* dont delete current session id

* Update server/src/dtos/auth.dto.ts

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

* rename event and invalidateOtherSessions

* chore: cleanup

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-10-27 13:16:10 +00:00
Min Idzelis
6bb1a9e083 fix: incomplete dynamic imports (#23217) 2025-10-27 08:45:30 -04:00
Mert
3f03a88767 feat(web): wasm justified layout, sync edition (#23194)
* the invisible wasm

use npm version

* deterministic tests

* add todo

* linting

* bump library, add helpers

* use target height for unfilled rows

* update tests
2025-10-25 00:06:05 -05:00
Jason Rasmussen
328380cfda refactor: websocket repository (#23228) 2025-10-24 16:26:27 -04:00
Robin Jacobs
65f29afb0f feat(cli): add --delete-duplicates option (#20035)
* Add --delete-duplicates option to delete local assets that already exist on the server, fixes #12181

* Update docs

* Fix `--delete-duplicates` implying `--delete`

* fix the test, break the english

* format

* also ran the formatter on the e2e folder :)

* early return, fewer allocations

* add back log

---------

Co-authored-by: Robin Jacobs <robin.jacobs@beeline.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-10-24 19:31:54 +00:00
andre-antunesdesa
f721a62776 feat(web): load original videos (#20041)
* added user preference for always loading original video

added ability to toggle between transcoded/original in the video viewer

add fix to static check error

* address PR comments

* Update asset-viewer-nav-bar.svelte

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

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-10-24 19:03:51 +00:00
Mert
c73e3dacea feat(mobile): high precision seeking (#22346)
* millisecond precision video playback

* wrap in unawaited

* update commit
2025-10-24 18:59:30 +00:00
Dag Stuan
78fb815cdb feat(web): add search filter for camera lens model. (#21792) 2025-10-24 14:41:34 -04:00
Jason Rasmussen
d9cddeb0f1 chore: use reverse proxy during local preview (#23184) 2025-10-24 14:00:51 -04:00
bo0tzz
c4ff2ea6d5 fix: actually use tf output (#23221) 2025-10-24 17:07:05 +02:00
renovate[bot]
b91b855473 chore(deps): update github-actions (major) (#22919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-24 16:21:41 +02:00
bo0tzz
7773d6d44f chore: update multi-runner-build-workflow (#23183) 2025-10-24 14:08:04 +00:00
idubnori
2129f889f5 feat: (mobile) open asset viewer from album activity page (#23182)
* feat(mobile): open assetviewer via album activities page

* adjust ui behavior: keep current asset & disable initial forcus

* fix: Run 'make build' and 'make pigeon'
2025-10-24 09:02:56 -05:00
shenlong
221e0ef02f fix: android skip posting hash response after detached from engine (#23192)
fix: native cancellations for hashing

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-24 08:56:49 -05:00
Basharat Ahmad Khan
0a6b2ad26e feat(web): reactively update shared link expiration (#22274)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-10-24 13:18:49 +02:00
renovate[bot]
719bf763e4 chore(deps): update prom/prometheus docker digest to 23031bf (#23111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-24 13:16:11 +02:00
Lauritz Tieste
34bad1ce71 feat: improvements of thumbnail animations (#20300)
* feat: improve thumbnail border radius animation

feat: remove thin border between image and image selection container

feat: enhance selection icon in thumbnail image

feat: add animated selection indicator for multiselect in thumbnail image

feat: remove unnecessary widgets and variables

style: format code

fix: errors with formatting checks

* chore: port to new timeline

* chore: revert mobile/lib/widgets/asset_grid/thumbnail_image.dart

---------

Co-authored-by: bwees <brandonwees@gmail.com>
2025-10-23 22:36:49 -05:00
Szymon Łągiewka
6164b027e2 chore(dep): bump ioredis to 5.8.2 (#23130) 2025-10-23 22:29:18 -05:00
Alex
d9a13dc8ac chore: gha ios release | take 4 (#23202) 2025-10-23 16:06:55 -05:00
Alex
722dbfa11f chore: gha ios release | take 3 (#23200) 2025-10-23 15:48:44 -05:00
Alex
f8afef0f9d chore: gha ios release | take 3 (#23199)
* chore: gha ios release | take 3

* chore: gha ios release | take 3
2025-10-23 20:35:43 +00:00
bo0tzz
3c8df55986 fix: add bundle platform arm64-darwin-23 (#23197) 2025-10-23 20:19:44 +00:00
Alex
47436ad0ce feat: GHA for iOS release flow (#23196) 2025-10-23 21:57:19 +02:00
Zack Pollard
9b58d5663a feat: support database dumps for pg18 (#23186) 2025-10-23 10:14:01 -04:00
Matthew Momjian
b6cebb3ece feat(server): pin to v2 (#23170)
* pin to v2

* remove release
2025-10-22 16:06:00 -04:00
Jason Rasmussen
cb7e68a287 refactor: user edit modal (#23169) 2025-10-22 15:21:16 -04:00
Jason Rasmussen
e196cac6f4 refactor: asset description modal (#23168) 2025-10-22 13:08:59 -05:00
Jason Rasmussen
351c0d2a4d refactor: user delete confirm modal (#23166) 2025-10-22 13:49:06 -04:00
Alex
f4969694cd fix: isolate freeze app on older ios device (#22509)
* fix: isolate freeze app on older ios device

* always use at-least 5 isolates

* fix: bad merge

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-22 12:43:03 -05:00
Daniel Dietzler
b334288529 fix: session list text color (#23165) 2025-10-22 17:33:54 +00:00
Jason Rasmussen
834e52fda6 refactor: user delete (#23163) 2025-10-22 12:54:29 -04:00
Jason Rasmussen
8c27ba3e52 refactor: job events (#23161) 2025-10-22 12:16:55 -04:00
aviv926
cd8d66f5dd fix(web): show upload speed (#23138)
* remove unnecessary else

* Better fix

* fix: update text color

* chore: stylings

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-10-22 15:40:10 +00:00
Nykri
446f738c7d chore: set default concurrency number to #CPU cores - 1 (#22888)
Set default concurrency number to #CPU cores - 1

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2025-10-22 10:16:07 -05:00
shenlong
f19ad9726f chore(dep): minor mobile dependency updates (#23126)
* chore(dep): minor dependency updates

* build_runner changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-22 10:14:44 -05:00
Brandon Wees
65cac118ca fix: allow editing all images (#23144)
* fix: allow editing local asset

* chore: remove isOwner check
2025-10-22 10:12:32 -05:00
Brandon Wees
efac8c6667 fix: semver parser grab everything before hyphen (#23140)
used for versions like 2.1.0-DEBUG
2025-10-22 10:06:40 -05:00
Jason Rasmussen
a70843e2b4 refactor: users.total metric (#23158)
* refactor: users.total metric

* fix: broken test
2025-10-22 10:18:17 -04:00
bo0tzz
0b941d78c4 fix: set TG_NON_INTERACTIVE (#23153) 2025-10-22 13:22:45 +01:00
bo0tzz
fc5fc58759 fix: bump tofu (#23152) 2025-10-22 11:13:03 +00:00
bo0tzz
9bb2fc238a fix: don't use app for final close-duplicates request (#23143) 2025-10-22 11:00:31 +00:00
Alex
76f5036026 chore: improve onboarding, app download links styling (#23134) 2025-10-21 21:10:12 -05:00
aviv926
032de9ff2f feat: view the user's app version on the user page (#21345)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-10-22 00:36:18 +02:00
1176 changed files with 73940 additions and 20271 deletions

View File

@@ -29,6 +29,12 @@
]
}
},
"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": {

View File

@@ -21,6 +21,7 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []
immich-machine-learning:

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
22.20.0
24.11.1

2
.github/labeler.yml vendored
View File

@@ -31,7 +31,7 @@ documentation:
🧠machine-learning:
- changed-files:
- any-glob-to-any-file:
- machine-learning/app/**
- 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 ."

View File

@@ -4,6 +4,6 @@
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.5.3"
"prettier": "^3.7.4"
}
}

View File

@@ -1,12 +1,16 @@
name: Build Mobile
on:
workflow_dispatch:
workflow_call:
inputs:
ref:
required: false
type: string
environment:
description: 'Target environment'
required: true
default: 'development'
type: string
secrets:
KEY_JKS:
required: true
@@ -16,6 +20,30 @@ on:
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
IOS_PROVISIONING_PROFILE:
required: true
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
FASTLANE_TEAM_ID:
required: true
pull_request:
push:
branches: [main]
@@ -68,7 +96,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -137,7 +165,7 @@ jobs:
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
@@ -154,3 +182,150 @@ jobs:
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-latest
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
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'
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
working-directory: ./mobile/ios
run: |
pod install
- name: Install Fastlane
working-directory: ./mobile/ios
run: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle 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 and Provisioning Profiles
env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios
run: |
# Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
# Decode provisioning profiles based on environment
if [[ "$ENVIRONMENT" == "development" ]]; then
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
ls -lh profile_dev*.mobileprovision
else
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
ls -lh profile*.mobileprovision
fi
- name: Create keychain and import certificate
env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
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 }}
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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa

View File

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

View File

@@ -35,7 +35,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
@@ -78,13 +78,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -95,7 +95,7 @@ jobs:
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ steps.token.outputs.token }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get package version
id: package-version
@@ -105,7 +105,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
flavor: |
latest=false
@@ -125,4 +125,3 @@ jobs:
cache-to: type=gha,mode=max
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
github-token: ${{ steps.token.outputs.token }}

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
@@ -54,16 +54,10 @@ jobs:
issues: write
discussions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Close issue
if: ${{ github.event_name == 'issues' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.issue.node_id }}
run: |
gh api graphql \
@@ -89,7 +83,7 @@ jobs:
- name: Close discussion
if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.discussion.node_id }}
run: |
gh api graphql \

View File

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

View File

@@ -116,24 +116,23 @@ jobs:
matrix:
include:
- device: cpu
tag-suffix: ''
- device: cuda
tag-suffix: '-cuda'
suffixes: '-cuda'
platforms: linux/amd64
- device: openvino
tag-suffix: '-openvino'
suffixes: '-openvino'
platforms: linux/amd64
- device: armnn
tag-suffix: '-armnn'
suffixes: '-armnn'
platforms: linux/arm64
- device: rknn
tag-suffix: '-rknn'
suffixes: '-rknn'
platforms: linux/arm64
- device: rocm
tag-suffix: '-rocm'
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read
@@ -147,7 +146,7 @@ jobs:
dockerfile: machine-learning/Dockerfile
platforms: ${{ matrix.platforms }}
runner-mapping: ${{ matrix.runner-mapping }}
tag-suffix: ${{ matrix.tag-suffix }}
suffixes: ${{ matrix.suffixes }}
dockerhub-push: ${{ github.event_name == 'release' }}
build-args: |
DEVICE=${{ matrix.device }}
@@ -156,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read

View File

@@ -60,7 +60,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'
@@ -85,7 +85,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: docs-build-output
path: docs/build/

View File

@@ -5,6 +5,9 @@ on:
types:
- completed
env:
TG_NON_INTERACTIVE: 'true'
jobs:
checks:
name: Docs Deploy Checks
@@ -122,7 +125,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -171,7 +174,7 @@ jobs:
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 tf apply'
run: 'mise run //deployment:tf apply'
- name: Deploy Docs Subdomain Output
id: docs-output
@@ -182,15 +185,11 @@ jobs:
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 tf output -json'
- name: Output Cleaning
id: clean
env:
TG_OUTPUT: ${{ steps.docs-output.outputs.tg_action_output }}
run: |
CLEANED=$(echo "$TG_OUTPUT" | sed 's|%0A|\n|g ; s|%3C|<|g' | jq -c .)
echo "output=$CLEANED" >> $GITHUB_OUTPUT
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
# TODO: Action is deprecated
@@ -198,7 +197,7 @@ jobs:
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ fromJson(steps.clean.outputs.output).pages_project_name.value }}
projectName: ${{ steps.docs-output.outputs.projectName }}
workingDirectory: 'docs'
directory: 'build'
branch: ${{ steps.parameters.outputs.name }}
@@ -212,7 +211,7 @@ jobs:
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 tf apply'
run: 'mise run //deployment:tf apply'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
@@ -221,6 +220,6 @@ jobs:
token: ${{ steps.token.outputs.token }}
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
body: |
📖 Documentation deployed to [${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }}](https://${{ fromJson(steps.clean.outputs.output).immich_app_branch_subdomain.value }})
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
emojis: 'rocket'
body-include: '<!-- Docs PR URL -->'

View File

@@ -5,6 +5,9 @@ on:
permissions: {}
env:
TG_NON_INTERACTIVE: 'true'
jobs:
deploy:
name: Docs Destroy
@@ -20,7 +23,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -36,7 +39,7 @@ jobs:
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 tf destroy -refresh=false'
run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0

View File

@@ -16,13 +16,13 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}
@@ -32,14 +32,14 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Fix formatting
run: make install-all && make format-all
run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
- name: Commit and push
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4

View File

@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,26 +49,26 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -99,8 +99,23 @@ jobs:
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 }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: ${{ needs.bump_version.outputs.ref }}
environment: production
prepare_release:
runs-on: ubuntu-latest
@@ -111,25 +126,25 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

170
.github/workflows/release-pr.yml vendored Normal file
View File

@@ -0,0 +1,170 @@
name: Manage release PR
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Determine release type
id: bump-type
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Bump versions
env:
TYPE: ${{ steps.bump-type.outputs.bump }}
run: |
if [ "$TYPE" == "none" ]; then
exit 1 # TODO: Is there a cleaner way to abort the workflow?
fi
misc/release/pump-version.sh -s $TYPE -m true
- name: Manage Outline release document
id: outline
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const fs = require('fs');
const outlineKey = process.env.OUTLINE_API_KEY;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
let documentId;
let documentUrl;
let documentText;
if (!document) {
// Create new document
console.log('No existing document found. Creating new one...');
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'next',
text: notesTmpl,
collectionId: collectionId,
parentDocumentId: parentDocumentId,
publish: true
})
});
if (!createResponse.ok) {
throw new Error(`Failed to create document: ${createResponse.statusText}`);
}
const createData = await createResponse.json();
documentId = createData.data.id;
const urlId = createData.data.urlId;
documentUrl = `${baseUrl}/doc/next-${urlId}`;
documentText = createData.data.text || '';
console.log(`Created new document: ${documentUrl}`);
} else {
documentId = document.id;
const docPath = document.url;
documentUrl = `${baseUrl}${docPath}`;
documentText = document.text || '';
console.log(`Found existing document: ${documentUrl}`);
}
// Generate GitHub release notes
console.log('Generating GitHub release notes...');
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `${process.env.NEXT_VERSION}`,
});
// Combine the content
const changelog = `
# ${process.env.NEXT_VERSION}
${documentText}
${releaseNotesResponse.data.body}
---
`
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
core.setOutput('document_url', documentUrl);
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
labels: 'changelog:skip'
branch: 'release/next'
draft: true

148
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,148 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
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 }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
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 }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
draft: true
files: |
docker/docker-compose.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}

View File

@@ -22,7 +22,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

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

View File

@@ -69,7 +69,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -114,14 +114,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -161,14 +161,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -203,14 +203,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -247,14 +247,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -285,14 +285,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -333,14 +333,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -379,14 +379,15 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -417,7 +418,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -425,7 +426,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -472,7 +473,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -480,7 +481,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -499,8 +500,16 @@ jobs:
run: docker compose build
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
@@ -525,7 +534,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -557,13 +566,13 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11
@@ -601,14 +610,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -630,7 +639,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -652,14 +661,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -714,14 +723,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -36,8 +36,7 @@ jobs:
github-token: ${{ steps.token.outputs.token }}
filters: |
i18n:
- 'i18n/!(en)**\.json'
exclude-branches: 'chore/translations'
- modified: 'i18n/!(en)**\.json'
skip-force-logic: 'true'
enforce-lock:

View File

@@ -52,7 +52,7 @@
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"eslint.validate": ["javascript", "typescript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",

97
CHANGELOG.md Normal file
View File

@@ -0,0 +1,97 @@
# v2.4.0
## Highlights
{{RELEASE HIGHLIGHTS}}
As always, please consider supporting the project.
🎉 Cheers! 🎉
----
And as always, bugs are fixed, and many other improvements also come with this release.
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### 🫥 Deprecated Changes
* feat: queues by @jrasm91 in https://github.com/immich-app/immich/pull/24142
### 🚀 Features
* feat: improve performance: don't sort timeline buckets from server by @midzelis in https://github.com/immich-app/immich/pull/24032
* feat: command palette by @danieldietzler in https://github.com/immich-app/immich/pull/23693
* feat(web): Shared album owner labels by @xCJPECKOVERx in https://github.com/immich-app/immich/pull/21171
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in https://github.com/immich-app/immich/pull/22133
* feat: queue detail page by @jrasm91 in https://github.com/immich-app/immich/pull/24352
* chore(mobile): add kebabu menu in asset viewer by @idubnori in https://github.com/immich-app/immich/pull/24387
### 🌟 Enhancements
* feat(web): allow navigating the map with arrow keys by @lukashass in https://github.com/immich-app/immich/pull/24080
* feat: separate camera and lens info in detail panel by @fabianbees in https://github.com/immich-app/immich/pull/23670
* feat(web): shared link card tweaks by @jrasm91 in https://github.com/immich-app/immich/pull/24192
* feat(server): exclude syncthing folders from external libraries by @SaphuA in https://github.com/immich-app/immich/pull/24240
* feat(web): search type selection dropdown by @YarosMallorca in https://github.com/immich-app/immich/pull/24091
* feat: header context menu by @jrasm91 in https://github.com/immich-app/immich/pull/24374
### 🐛 Bug fixes
* fix: effect loop by @jrasm91 in https://github.com/immich-app/immich/pull/24014
* fix: do not clear hash on updated_at change by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24039
* fix: disable animation "add to" action menu by @bwees in https://github.com/immich-app/immich/pull/24040
* fix: Use correct app store link by @Mraedis in https://github.com/immich-app/immich/pull/24062
* fix: show archived assets in favorite page by @bwees in https://github.com/immich-app/immich/pull/24052
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in https://github.com/immich-app/immich/pull/23906
* feat(web): show detected faces in spherical photos by @meesfrensel in https://github.com/immich-app/immich/pull/23974
* fix: add users to album by @danieldietzler in https://github.com/immich-app/immich/pull/24133
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in https://github.com/immich-app/immich/pull/23333
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24137
* fix: albums page reactivity loops by @danieldietzler in https://github.com/immich-app/immich/pull/24046
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24131
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in https://github.com/immich-app/immich/pull/24018
* fix: don't get OCR data in shared link by @alextran1502 in https://github.com/immich-app/immich/pull/24152
* fix: duration extraction by @jrasm91 in https://github.com/immich-app/immich/pull/24178
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in https://github.com/immich-app/immich/pull/24045
* fix: update timeline-manager after archive actions by @midzelis in https://github.com/immich-app/immich/pull/24010
* fix: theme switcher by @jrasm91 in https://github.com/immich-app/immich/pull/24209
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in https://github.com/immich-app/immich/pull/24232
* fix(mobile): enable backup text overflows by @YarosMallorca in https://github.com/immich-app/immich/pull/24227
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in https://github.com/immich-app/immich/pull/24189
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in https://github.com/immich-app/immich/pull/24249
* fix: only generate memory based on users assets by @alextran1502 in https://github.com/immich-app/immich/pull/24151
* fix(mobile): docs link by @mmomjian in https://github.com/immich-app/immich/pull/24277
* fix(server): use bigrams for cjk by @mertalev in https://github.com/immich-app/immich/pull/24285
* fix(ml): do not upscale preview by @mertalev in https://github.com/immich-app/immich/pull/24322
* fix(web): open onboarding documentation link in new tab by @carbonemys in https://github.com/immich-app/immich/pull/24289
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in https://github.com/immich-app/immich/pull/24310
* fix(web): folder view sort oder by @etnoy in https://github.com/immich-app/immich/pull/24337
* fix(server): do not delete offline assets by @mertalev in https://github.com/immich-app/immich/pull/24355
* fix: exposure info and better readability by @alextran1502 in https://github.com/immich-app/immich/pull/24344
* fix: Adjust the zoom level by @jforseth210 in https://github.com/immich-app/immich/pull/24353
* fix: local full sync on Android on resume by @alextran1502 in https://github.com/immich-app/immich/pull/24348
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in https://github.com/immich-app/immich/pull/24372
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24047
### 📚 Documentation
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in https://github.com/immich-app/immich/pull/24215
* fix(docs): build `cli` for e2e tests by @roschaefer in https://github.com/immich-app/immich/pull/24184
* docs(faq): add more info on archiving by @etnoy in https://github.com/immich-app/immich/pull/24326
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in https://github.com/immich-app/immich/pull/24335
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in https://github.com/immich-app/immich/pull/24351
* fix(docs): obsolete docs about rootless docker by @roschaefer in https://github.com/immich-app/immich/pull/24376
### 🌐 Translations
* chore: add new language requests by @danieldietzler in https://github.com/immich-app/immich/pull/23991
## New Contributors
* @ujjwal123123 made their first contribution in https://github.com/immich-app/immich/pull/24101
* @lutostag made their first contribution in https://github.com/immich-app/immich/pull/23333
* @LukaPrebil made their first contribution in https://github.com/immich-app/immich/pull/24045
* @kimsey0 made their first contribution in https://github.com/immich-app/immich/pull/24232
* @SaphuA made their first contribution in https://github.com/immich-app/immich/pull/24240
* @dionysius made their first contribution in https://github.com/immich-app/immich/pull/24215
* @NiklasvonM made their first contribution in https://github.com/immich-app/immich/pull/24249
* @kao-byte made their first contribution in https://github.com/immich-app/immich/pull/24098
* @carbonemys made their first contribution in https://github.com/immich-app/immich/pull/24289
* @kiloomar made their first contribution in https://github.com/immich-app/immich/pull/24372
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0
---

View File

@@ -17,6 +17,9 @@ dev-docs:
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

View File

@@ -118,16 +118,16 @@ Read more about translations [here](https://docs.immich.app/developer/translatio
## Star history
<a href="https://star-history.com/#immich-app/immich&Date">
<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%" />
<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/alextran1502/immich/graphs/contributors">
<a href="https://github.com/immich-app/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

View File

@@ -1 +1 @@
22.20.0
24.11.1

View File

@@ -1,4 +1,4 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./

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"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.97",
"version": "2.2.104",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.18.10",
"@types/node": "^24.10.1",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -28,10 +28,10 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "22.20.0"
"node": "24.11.1"
}
}

View File

@@ -271,7 +271,7 @@ describe('startWatch', () => {
});
});
it('should filger out ignored patterns', async () => {
it('should filter out ignored patterns', async () => {
const testFilePath = path.join(testFolder, 'test.jpg');
const ignoredPattern = 'ignored';
const ignoredFolder = path.join(testFolder, ignoredPattern);

View File

@@ -37,6 +37,7 @@ export interface UploadOptionsDto {
dryRun?: boolean;
skipHash?: boolean;
delete?: boolean;
deleteDuplicates?: boolean;
album?: boolean;
albumName?: string;
includeHidden?: boolean;
@@ -70,10 +71,8 @@ const uploadBatch = async (files: string[], options: UploadOptionsDto) => {
console.log(JSON.stringify({ newFiles, duplicates, newAssets }, undefined, 4));
}
await updateAlbums([...newAssets, ...duplicates], options);
await deleteFiles(
newAssets.map(({ filepath }) => filepath),
options,
);
await deleteFiles(newAssets, duplicates, options);
};
export const startWatch = async (
@@ -406,28 +405,46 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
return response.json();
};
const deleteFiles = async (files: string[], options: UploadOptionsDto): Promise<void> => {
if (!options.delete) {
return;
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 ${files.length} local asset${s(files.length)}`);
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(files.length, 0);
deletionProgress.start(fileCount, 0);
const chunkDelete = async (files: Asset[]) => {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
deletionProgress.update(assetBatch.length);
}
};
try {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: string) => unlink(input)));
deletionProgress.update(assetBatch.length);
if (options.delete) {
await chunkDelete(uploaded);
}
if (options.deleteDuplicates) {
await chunkDelete(duplicates);
}
} finally {
deletionProgress.stop();

View File

@@ -8,6 +8,7 @@ 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')
@@ -66,7 +67,7 @@ program
.addOption(
new Option('-c, --concurrency <number>', 'Number of assets to upload at the same time')
.env('IMMICH_UPLOAD_CONCURRENCY')
.default(4),
.default(defaultConcurrency),
)
.addOption(
new Option('-j, --json-output', 'Output detailed information in json format')
@@ -74,6 +75,11 @@ program
.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')

View File

@@ -299,7 +299,7 @@ describe('crawl', () => {
.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)).sort()).toEqual(expected.sort());
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
});
}
});

View File

@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
ignore: [`**/${exclusionPattern}`],
});
globbedFiles.push(...crawledFiles);
return globbedFiles.sort();
return globbedFiles.toSorted();
};
export const sha1 = (filepath: string) => {

View File

@@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

20
deployment/mise.toml Normal file
View File

@@ -0,0 +1,20 @@
[tools]
terragrunt = "0.93.10"
opentofu = "1.10.7"
[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

@@ -41,6 +41,7 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
env_file:
- .env
environment:
@@ -57,10 +58,6 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports:
- 9230:9230
- 9231:9231
@@ -99,10 +96,6 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
immich-server:
@@ -122,7 +115,7 @@ services:
ports:
- 3003:3003
volumes:
- ../machine-learning:/usr/src/app
- ../machine-learning/immich_ml:/usr/src/immich_ml
- model-cache:/cache
env_file:
- .env
@@ -134,7 +127,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.1.1-ubuntu@sha256:d1da838234ff2de93e0065ee1bf0e66d38f948dcc5d718c25fa6237e14b4424a
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -9,8 +9,8 @@ DB_DATA_LOCATION=./postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
IMMICH_VERSION=release
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
# 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

View File

@@ -1 +1 @@
22.20.0
24.11.1

View File

@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
the job may not have run automatically the first time.
### How can I hide photos from the timeline?
### How can I hide a photo or video from the timeline?
You can _archive_ them.
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
### How can I backup data from Immich?

View File

@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
#### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
This dumps will count towards the last `X` dumps that will be kept based on your settings.

View File

@@ -0,0 +1,18 @@
# Maintenance Mode
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
You can enter maintenance mode by either:
- Selecting "enable maintenance mode" in system settings in administration.
- Running the enable maintenance mode [administration command](./server-commands.md).
## Logging in during maintenance
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
If you find that you've been logged out, you can:
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.

View File

@@ -10,16 +10,19 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
## Prerequisites
You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`.
You must install pgvector as it is a prerequisite for VectorChord.
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
:::note
Immich is known to work with Postgres versions `>= 14, < 18`.
:::note Supported versions
Immich is known to work with Postgres versions `>= 14, < 19`.
Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`.
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
:::
## Specifying the connection URL

View File

@@ -21,6 +21,9 @@ server {
# allow large file uploads
client_max_body_size 50000M;
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off;
# Set headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -2,17 +2,19 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| Command | Description |
| -------------------------- | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `disable-maintenance-mode` | Disable maintenance mode |
| `enable-maintenance-mode` | Enable maintenance mode |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
## How to run a command
@@ -47,6 +49,23 @@ immich-admin enable-password-login
Password login has been enabled.
```
Disable Maintenance Mode
```
immich-admin disable-maintenace-mode
Maintenance mode has been disabled.
```
Enable Maintenance Mode
```
immich-admin enable-maintenance-mode
Maintenance mode has been enabled.
Log in using the following URL:
https://my.immich.app/maintenance?token=<token>
```
Enable OAuth login
```

View File

@@ -12,3 +12,13 @@ pnpm run migrations:generate <migration-name>
3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
## Reverting a Migration
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
```bash
pnpm run migrations:revert
```
This command rolls back the latest migration and brings the database schema back to its previous state.

View File

@@ -256,7 +256,7 @@ The Dev Container supports multiple ways to run tests:
```bash
# Run tests for specific components
make test-server # Server unit tests
make test-server # Server unit tests
make test-web # Web unit tests
make test-e2e # End-to-end tests
make test-cli # CLI tests
@@ -268,12 +268,13 @@ make test-all # Runs tests for all components
make test-medium-dev # End-to-end tests
```
#### Using NPM Directly
#### Using PNPM Directly
```bash
# Server tests
cd /workspaces/immich/server
pnpm test # Run all tests
pnpm test # Run all tests
pnpm run test:medium # Medium tests (integration tests)
pnpm run test:watch # Watch mode
pnpm run test:cov # Coverage report
@@ -293,21 +294,21 @@ pnpm run test:web # Run web UI tests
```bash
# Linting
make lint-server # Lint server code
make lint-web # Lint web code
make lint-all # Lint all components
make lint-web # Lint web code
make lint-all # Lint all components
# Formatting
make format-server # Format server code
make format-web # Format web code
make format-all # Format all code
make format-web # Format web code
make format-all # Format all code
# Type checking
make check-server # Type check server
make check-web # Type check web
make check-all # Check all components
make check-web # Type check web
make check-all # Check all components
# Complete hygiene check
make hygiene-all # Runs lint, format, check, SQL sync, and audit
make hygiene-all # Run lint, format, check, SQL sync, and audit
```
### Additional Make Commands
@@ -315,21 +316,21 @@ make hygiene-all # Runs lint, format, check, SQL sync, and audit
```bash
# Build commands
make build-server # Build server
make build-web # Build web app
make build-all # Build everything
make build-web # Build web app
make build-all # Build everything
# API generation
make open-api # Generate OpenAPI specs
make open-api # Generate OpenAPI specs
make open-api-typescript # Generate TypeScript SDK
make open-api-dart # Generate Dart SDK
make open-api-dart # Generate Dart SDK
# Database
make sql # Sync database schema
make sql # Sync database schema
# Dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
```
### Debugging

View File

@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
- [ ] `pnpm run check:typescript` (check typescript)
- [ ] `pnpm test` (unit tests)
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Documentation
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Server Checks
- [ ] `pnpm run lint` (linting via ESLint)

View File

@@ -5,7 +5,7 @@ sidebar_position: 2
# Setup
:::note
If there's a feature you're planning to work on, just give us a heads up in [Discord](https://discord.com/channels/979116623879368755/1071165397228855327) so we can:
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
1. Let you know if it's something we would accept into Immich
2. Provide any guidance on how something like that would ideally be implemented
@@ -48,7 +48,6 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
**Notes:**
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
#### Connect web to a remote backend

View File

@@ -18,6 +18,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_:
- `pnpm install` (in `e2e/`)
- `pnpm run build` (in `cli/`)
- `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via:

View File

@@ -103,6 +103,7 @@ Options:
-c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY)
-j, --json-output Output detailed information in json format (default: false, env: IMMICH_JSON_OUTPUT)
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
--delete-duplicates Delete local assets that are duplicates (already exist on server) (env: IMMICH_DELETE_DUPLICATES)
--no-progress Hide progress bars (env: IMMICH_PROGRESS_BAR)
--watch Watch for changes and upload automatically (default: false, env: IMMICH_WATCH_CHANGES)
--help display help for command

View File

@@ -10,6 +10,16 @@ import MobileAppBackup from '/docs/partials/_mobile-app-backup.md';
<MobileAppDownload />
:::info Android verification
Below are the SHA-256 fingerprints for the certificates signing the android applications.
- Playstore / Github releases:
`86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20`
- F-Droid releases:
`FA:8B:43:95:F4:A6:47:71:A0:53:D1:C7:57:73:5F:A2:30:13:74:F5:3D:58:0D:D1:75:AA:F7:A1:35:72:9C:BF`
:::
:::info Beta Program
The beta release channel allows users to test upcoming changes before they are officially released. To join the channel use the links below.

View File

@@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
[job-status-page]: https://my.immich.app/admin/jobs-status
[job-status-page]: https://my.immich.app/admin/queues

View File

@@ -106,14 +106,14 @@ SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset"
```sql title="Count by tag"
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
```
```sql title="Count by tag (per user)"
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
WHERE "a"."visibility" != 'hidden'
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
```

View File

@@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
## Load balancing

View File

@@ -16,48 +16,76 @@ The default configuration looks like this:
```json
{
"ffmpeg": {
"crf": 23,
"threads": 0,
"preset": "ultrafast",
"targetVideoCodec": "h264",
"acceptedVideoCodecs": ["h264"],
"targetAudioCodec": "aac",
"acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"],
"acceptedContainers": ["mov", "ogg", "webm"],
"targetResolution": "720",
"maxBitrate": "0",
"bframes": -1,
"refs": 0,
"gopSize": 0,
"temporalAQ": false,
"cqMode": "auto",
"twoPass": false,
"preferredHwDevice": "auto",
"transcode": "required",
"tonemap": "hable",
"accel": "disabled",
"accelDecode": false
},
"backup": {
"database": {
"enabled": true,
"cronExpression": "0 02 * * *",
"enabled": true,
"keepLastAmount": 14
}
},
"ffmpeg": {
"accel": "disabled",
"accelDecode": false,
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
"acceptedContainers": ["mov", "ogg", "webm"],
"acceptedVideoCodecs": ["h264"],
"bframes": -1,
"cqMode": "auto",
"crf": 23,
"gopSize": 0,
"maxBitrate": "0",
"preferredHwDevice": "auto",
"preset": "ultrafast",
"refs": 0,
"targetAudioCodec": "aac",
"targetResolution": "720",
"targetVideoCodec": "h264",
"temporalAQ": false,
"threads": 0,
"tonemap": "hable",
"transcode": "required",
"twoPass": false
},
"image": {
"colorspace": "p3",
"extractEmbedded": false,
"fullsize": {
"enabled": false,
"format": "jpeg",
"quality": 80
},
"preview": {
"format": "jpeg",
"quality": 80,
"size": 1440
},
"thumbnail": {
"format": "webp",
"quality": 80,
"size": 250
}
},
"job": {
"backgroundTask": {
"concurrency": 5
},
"smartSearch": {
"faceDetection": {
"concurrency": 2
},
"library": {
"concurrency": 5
},
"metadataExtraction": {
"concurrency": 5
},
"faceDetection": {
"concurrency": 2
"migration": {
"concurrency": 5
},
"notifications": {
"concurrency": 5
},
"ocr": {
"concurrency": 1
},
"search": {
"concurrency": 5
@@ -65,20 +93,23 @@ The default configuration looks like this:
"sidecar": {
"concurrency": 5
},
"library": {
"concurrency": 5
},
"migration": {
"concurrency": 5
"smartSearch": {
"concurrency": 2
},
"thumbnailGeneration": {
"concurrency": 3
},
"videoConversion": {
"concurrency": 1
}
},
"library": {
"scan": {
"cronExpression": "0 0 * * *",
"enabled": true
},
"notifications": {
"concurrency": 5
"watch": {
"enabled": false
}
},
"logging": {
@@ -86,8 +117,11 @@ The default configuration looks like this:
"level": "log"
},
"machineLearning": {
"enabled": true,
"urls": ["http://immich-machine-learning:3003"],
"availabilityChecks": {
"enabled": true,
"interval": 30000,
"timeout": 2000
},
"clip": {
"enabled": true,
"modelName": "ViT-B-32__openai"
@@ -96,27 +130,59 @@ The default configuration looks like this:
"enabled": true,
"maxDistance": 0.01
},
"enabled": true,
"facialRecognition": {
"enabled": true,
"modelName": "buffalo_l",
"minScore": 0.7,
"maxDistance": 0.5,
"minFaces": 3
}
"minFaces": 3,
"minScore": 0.7,
"modelName": "buffalo_l"
},
"ocr": {
"enabled": true,
"maxResolution": 736,
"minDetectionScore": 0.5,
"minRecognitionScore": 0.8,
"modelName": "PP-OCRv5_mobile"
},
"urls": ["http://immich-machine-learning:3003"]
},
"map": {
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json",
"enabled": true,
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json",
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json"
},
"reverseGeocoding": {
"enabled": true
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json"
},
"metadata": {
"faces": {
"import": false
}
},
"newVersionCheck": {
"enabled": true
},
"nightlyTasks": {
"clusterNewFaces": true,
"databaseCleanup": true,
"generateMemories": true,
"missingThumbnails": true,
"startTime": "00:00",
"syncQuotaUsage": true
},
"notifications": {
"smtp": {
"enabled": false,
"from": "",
"replyTo": "",
"transport": {
"host": "",
"ignoreCert": false,
"password": "",
"port": 587,
"secure": false,
"username": ""
}
}
},
"oauth": {
"autoLaunch": false,
"autoRegister": true,
@@ -128,70 +194,44 @@ The default configuration looks like this:
"issuerUrl": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"profileSigningAlgorithm": "none",
"roleClaim": "immich_role",
"scope": "openid email profile",
"signingAlgorithm": "RS256",
"profileSigningAlgorithm": "none",
"storageLabelClaim": "preferred_username",
"storageQuotaClaim": "immich_quota"
"storageQuotaClaim": "immich_quota",
"timeout": 30000,
"tokenEndpointAuthMethod": "client_secret_post"
},
"passwordLogin": {
"enabled": true
},
"reverseGeocoding": {
"enabled": true
},
"server": {
"externalDomain": "",
"loginPageMessage": "",
"publicUsers": true
},
"storageTemplate": {
"enabled": false,
"hashVerificationEnabled": true,
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
},
"image": {
"thumbnail": {
"format": "webp",
"size": 250,
"quality": 80
},
"preview": {
"format": "jpeg",
"size": 1440,
"quality": 80
},
"colorspace": "p3",
"extractEmbedded": false
},
"newVersionCheck": {
"enabled": true
},
"trash": {
"enabled": true,
"days": 30
"templates": {
"email": {
"albumInviteTemplate": "",
"albumUpdateTemplate": "",
"welcomeTemplate": ""
}
},
"theme": {
"customCss": ""
},
"library": {
"scan": {
"enabled": true,
"cronExpression": "0 0 * * *"
},
"watch": {
"enabled": false
}
},
"server": {
"externalDomain": "",
"loginPageMessage": ""
},
"notifications": {
"smtp": {
"enabled": false,
"from": "",
"replyTo": "",
"transport": {
"ignoreCert": false,
"host": "",
"port": 587,
"username": "",
"password": ""
}
}
"trash": {
"days": 30,
"enabled": true
},
"user": {
"deleteDelay": 7

View File

@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
## Ports
| Variable | Description | Default |
| :------------ | :------------- | :----------------------------------------: |
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
| Variable | Description | Default | Containers |
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
## Database
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.
@@ -149,29 +149,31 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.

25
docs/mise.toml Normal file
View File

@@ -0,0 +1,25 @@
[tasks.install]
run = "pnpm install --filter documentation --frozen-lockfile"
[tasks.start]
env._.path = "./node_modules/.bin"
run = "docusaurus --port 3005"
[tasks.build]
env._.path = "./node_modules/.bin"
run = [
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"docusaurus build",
]
[tasks.preview]
env._.path = "./node_modules/.bin"
run = "docusaurus serve"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."

View File

@@ -38,7 +38,7 @@
"@docusaurus/module-type-aliases": "~3.9.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.2.4",
"prettier": "^3.7.4",
"typescript": "^5.1.6"
},
"browserslist": {
@@ -57,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "22.20.0"
"node": "24.11.1"
}
}

View File

@@ -1,4 +1,32 @@
[
{
"label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app"
},
{
"label": "v2.3.1",
"url": "https://docs.v2.3.1.archive.immich.app"
},
{
"label": "v2.3.0",
"url": "https://docs.v2.3.0.archive.immich.app"
},
{
"label": "v2.2.3",
"url": "https://docs.v2.2.3.archive.immich.app"
},
{
"label": "v2.2.2",
"url": "https://docs.v2.2.2.archive.immich.app"
},
{
"label": "v2.2.1",
"url": "https://docs.v2.2.1.archive.immich.app"
},
{
"label": "v2.2.0",
"url": "https://docs.v2.2.0.archive.immich.app"
},
{
"label": "v2.1.0",
"url": "https://docs.v2.1.0.archive.immich.app"

1
e2e/.gitignore vendored
View File

@@ -4,3 +4,4 @@ node_modules/
/blob-report/
/playwright/.cache/
/dist
.env

View File

@@ -1 +1 @@
22.20.0
24.11.1

105
e2e/docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,105 @@
name: immich-e2e
services:
immich-server:
container_name: immich-e2e-server
command: ['immich-dev']
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_TELEMETRY_INCLUDE=all
- IMMICH_ENV=testing
- IMMICH_PORT=2285
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
volumes:
- ./test-assets:/test-assets
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
depends_on:
redis:
condition: service_started
database:
condition: service_healthy
immich-web:
container_name: immich-e2e-web
image: immich-web-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command: ['immich-web']
ports:
- 2285:3000
environment:
- IMMICH_SERVER_URL=http://immich-server:2285/
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: immich
ports:
- 5435:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
interval: 1s
timeout: 5s
retries: 30
start_period: 10s
volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-store:
server-node_modules:
web-node_modules:
github-node_modules:
cli-node_modules:
docs-node_modules:
e2e-node_modules:
sdk-node_modules:
app-node_modules:
sveltekit:
coverage:

View File

@@ -7,6 +7,9 @@ services:
build:
context: ../
dockerfile: server/Dockerfile
cache_from:
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
args:
- BUILD_ID=1234567890
- BUILD_IMAGE=e2e
@@ -35,7 +38,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

29
e2e/mise.toml Normal file
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.1.0",
"version": "2.4.0",
"description": "",
"main": "index.js",
"type": "module",
@@ -20,30 +20,32 @@
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.18.10",
"@types/node": "^24.10.1",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"dotenv": "^17.2.3",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"exiftool-vendored": "^31.1.0",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^33.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
"oidc-provider": "^9.0.0",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.4",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@@ -52,6 +54,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "22.20.0"
"node": "24.11.1"
}
}

View File

@@ -1,23 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
import dotenv from 'dotenv';
import { cpus } from 'node:os';
import { resolve } from 'node:path';
export default defineConfig({
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
const config: PlaywrightTestConfig = {
testDir: './src/web/specs',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
retries: process.env.CI ? 4 : 0,
reporter: 'html',
use: {
baseURL: 'http://127.0.0.1:2285',
baseURL: playwriteBaseUrl,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
launchOptions: {
slowMo: playwriteSlowMo,
},
},
testMatch: /.*\.e2e-spec\.ts/,
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
{
name: 'parallel tests',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
@@ -59,4 +86,8 @@ export default defineConfig({
stderr: 'pipe',
reuseExistingServer: true,
},
});
};
if (playwrightDisableWebserver) {
delete config.webServer;
}
export default defineConfig(config);

View File

@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import sharp from 'sharp';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
// Generate unique color to ensure different checksums for each image
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Create a 100x100 solid color JPEG using Sharp
const imageBytes = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
// Add random suffix to filename to avoid collisions
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
const filepath = join(tempDir, uniqueFilename);
await writeFile(filepath, imageBytes);
// Filter out undefined values before writing EXIF
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
await exiftool.write(filepath, cleanExifData);
// Re-read the image bytes after EXIF has been written
const finalImageBytes = await readFile(filepath);
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
};
describe('/asset', () => {
let admin: LoginResponseDto;
let websocket: Socket;
@@ -1249,411 +1214,6 @@ describe('/asset', () => {
});
});
describe('EXIF metadata extraction', () => {
describe('Additional date tag extraction', () => {
describe('Date-time vs time-only tag handling', () => {
it('should fall back to file timestamps when only time-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
// Exclude all date-time tags to force fallback to file timestamps
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
SonyDateTime2: undefined,
GPSDateStamp: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should prefer DateTimeOriginal over time-only tags', async () => {
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal, not TimeCreated
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
describe('GPSDateTime tag extraction', () => {
it('should extract GPSDateTime with GPS coordinates', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: 37.7749,
GPSLongitude: -122.4194,
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T12:30:00.000Z').getTime(),
);
});
});
describe('CreateDate tag extraction', () => {
it('should extract CreateDate when available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
CreateDate: '2023:11:15 10:30:00',
// Exclude other higher priority date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T10:30:00.000Z').getTime(),
);
});
});
describe('GPSDateStamp tag extraction', () => {
it('should fall back to file timestamps when only date-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
GPSLatitude: 51.5074,
GPSLongitude: -0.1278,
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
DateTimeOriginal: undefined,
CreateDate: undefined,
CreationDate: undefined,
GPSDateTime: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
});
/*
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
*
* NOT WRITABLE to JPEG:
* - MediaCreateDate: Can be read from video files but not written to JPEG
* - DateTimeCreated: Read-only tag in JPEG format
* - DateTimeUTC: Cannot be written to JPEG files
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
* - SubSecMediaCreateDate: Tag not defined for JPEG format
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
*
* WRITABLE but NOT READABLE from JPEG:
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
* - SubSecCreateDate: Can be written but not read back from JPEG
*
* EFFECTIVELY TESTABLE TAGS (writable and readable):
* - DateTimeOriginal ✓
* - CreateDate ✓
* - CreationDate ✓
* - GPSDateTime ✓
*
* The metadata service correctly handles non-readable tags and will fall back to
* file timestamps when only non-readable tags are present.
*/
describe('Date tag priority order', () => {
it('should respect the complete date tag priority order', async () => {
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
const testCases = [
{
name: 'DateTimeOriginal has highest priority among testable tags',
exifData: {
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-04-04T04:00:00.000Z',
},
{
name: 'CreationDate when DateTimeOriginal missing',
exifData: {
CreationDate: '2023:05:05 05:00:00', // TESTABLE
CreateDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-05-05T05:00:00.000Z',
},
{
name: 'CreationDate when standard EXIF tags missing',
exifData: {
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-07-07T07:00:00.000Z',
},
{
name: 'GPSDateTime when no other testable date tags present',
exifData: {
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
Make: 'SONY',
},
expectedDate: '2023-10-10T10:00:00.000Z',
},
];
for (const testCase of testCases) {
const { imageBytes, filename } = await createTestImageWithExif(
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
testCase.exifData,
);
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
expect(
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
`Date mismatch for: ${testCase.name}`,
).toBe(new Date(testCase.expectedDate).getTime());
}
});
});
describe('Edge cases for date tag handling', () => {
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
// Intentionally no GPSTimeStamp
// Exclude all other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should handle all testable date tags present to verify complete priority order', async () => {
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
// All TESTABLE date tags to JPEG format (writable AND readable)
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
// Note: Excluded non-testable tags:
// SubSec tags: writable but not readable from JPEG
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal as it has the highest priority among testable tags
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-04-04T04:00:00.000Z').getTime(),
);
});
it('should use CreationDate when SubSec tags are missing', async () => {
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
CreationDate: '2023:07:07 07:00:00', // WRITABLE
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
// Exclude SubSec and standard EXIF tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use CreationDate when available
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-07-07T07:00:00.000Z').getTime(),
);
});
it('should skip invalid date formats and use next valid tag', async () => {
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
// Note: Testing invalid date handling with only WRITABLE tags
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should skip invalid dates and use the first valid one (GPSDateTime)
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
});
});
describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, {

View File

@@ -1,4 +1,4 @@
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk';
import { cpSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
@@ -17,28 +17,28 @@ describe('/jobs', () => {
describe('PUT /jobs', () => {
afterEach(async () => {
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, {
command: QueueCommand.Resume,
force: false,
});
@@ -59,8 +59,8 @@ describe('/jobs', () => {
it('should queue metadata extraction for missing assets', async () => {
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Pause,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Pause,
force: false,
});
@@ -77,20 +77,20 @@ describe('/jobs', () => {
expect(asset.exifInfo?.make).toBeNull();
}
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Empty,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Empty,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Start,
force: false,
});
@@ -124,8 +124,8 @@ describe('/jobs', () => {
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
command: QueueCommand.Start,
force: false,
});
@@ -144,8 +144,8 @@ describe('/jobs', () => {
it('should queue thumbnail extraction for assets missing thumbs', async () => {
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Pause,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Pause,
force: false,
});
@@ -153,32 +153,32 @@ describe('/jobs', () => {
assetData: { bytes: await readFile(path), filename: basename(path) },
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
expect(assetBefore.thumbhash).toBeNull();
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Empty,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Empty,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
expect(assetAfter.thumbhash).not.toBeNull();
@@ -193,26 +193,26 @@ describe('/jobs', () => {
assetData: { bytes: await readFile(path), filename: basename(path) },
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Resume,
force: false,
});
// This runs the missing thumbnail job
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
command: QueueCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);

View File

@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],

View File

@@ -0,0 +1,172 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/admin/maintenance', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
// => outside of maintenance mode
describe('GET ~/server/config', async () => {
it('should indicate we are out of maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeFalsy();
});
});
describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
});
});
// => enter maintenance mode
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
action: 'end',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should be a no-op if try to exit maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(201);
});
it('should enter maintenance mode', async () => {
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'start',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
expect(cookie).toEqual(
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeTruthy();
});
});
// => in maintenance mode
describe.sequential('in maintenance mode', () => {
describe('GET ~/server/config', async () => {
it('should indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeTruthy();
});
});
describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
});
it('should succeed with cookie', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
it('should succeed with token', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance/login')
.send({
token: cookie!.split('=')[1].trim(),
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
});
describe('POST /', async () => {
it('should be a no-op if try to enter maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('cookie', cookie!)
.send({ action: 'start' });
expect(status).toBe(201);
});
});
});
// => exit maintenance mode
describe.sequential('POST /', () => {
it('should exit maintenance mode', async () => {
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeFalsy();
});
});
});

View File

@@ -113,6 +113,7 @@ describe('/server', () => {
importFaces: false,
oauth: false,
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
search: true,
sidecar: true,
@@ -135,6 +136,7 @@ describe('/server', () => {
externalDomain: '',
publicUsers: true,
isOnboarded: false,
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});

View File

@@ -582,7 +582,7 @@ describe('/tags', () => {
expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
});
it('should remove duplicate assets only once', async () => {
it.skip('should remove duplicate assets only once', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(
{ id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },

View File

@@ -1,5 +1,6 @@
import {
LoginResponseDto,
QueueName,
createStack,
deleteUserAdmin,
getMyUser,
@@ -327,6 +328,8 @@ describe('/admin/users', () => {
{ headers: asBearerAuth(user.accessToken) },
);
await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);
const { status, body } = await request(app)
.delete(`/admin/users/${user.userId}`)
.send({ force: true })

View File

@@ -442,6 +442,176 @@ describe(`immich upload`, () => {
});
});
describe('immich upload --delete-duplicates', () => {
it('should delete local duplicate files', async () => {
const {
stderr: firstStderr,
stdout: firstStdout,
exitCode: firstExitCode,
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(firstStderr).toContain('{message}');
expect(firstStdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
expect(firstExitCode).toBe(0);
await mkdir(`/tmp/albums/nature`, { recursive: true });
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`);
// Upload with --delete-duplicates flag
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`/tmp/albums/nature/silver_fir.jpg`,
'--delete-duplicates',
]);
// Check that the duplicate file was deleted
const files = await readdir(`/tmp/albums/nature`);
await rm(`/tmp/albums/nature`, { recursive: true });
expect(files.length).toBe(0);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Found 0 new files and 1 duplicate'),
expect.stringContaining('All assets were already uploaded, nothing to do'),
]),
);
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
// Verify no new assets were uploaded
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
expect(assets.total).toBe(1);
});
it('should have accurate dry run with --delete-duplicates', async () => {
const {
stderr: firstStderr,
stdout: firstStdout,
exitCode: firstExitCode,
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(firstStderr).toContain('{message}');
expect(firstStdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
expect(firstExitCode).toBe(0);
await mkdir(`/tmp/albums/nature`, { recursive: true });
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`);
// Upload with --delete-duplicates and --dry-run flags
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`/tmp/albums/nature/silver_fir.jpg`,
'--delete-duplicates',
'--dry-run',
]);
// Check that the duplicate file was NOT deleted in dry run mode
const files = await readdir(`/tmp/albums/nature`);
await rm(`/tmp/albums/nature`, { recursive: true });
expect(files.length).toBe(1);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Found 0 new files and 1 duplicate'),
expect.stringContaining('Would have deleted 1 local asset'),
]),
);
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
// Verify no new assets were uploaded
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
expect(assets.total).toBe(1);
});
it('should work with both --delete and --delete-duplicates flags', async () => {
// First, upload a file to create a duplicate on the server
const {
stderr: firstStderr,
stdout: firstStdout,
exitCode: firstExitCode,
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(firstStderr).toContain('{message}');
expect(firstStdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
expect(firstExitCode).toBe(0);
// Both new and duplicate files
await mkdir(`/tmp/albums/nature`, { recursive: true });
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`); // duplicate
await symlink(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `/tmp/albums/nature/el_torcal_rocks.jpg`); // new
// Upload with both --delete and --delete-duplicates flags
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`/tmp/albums/nature`,
'--delete',
'--delete-duplicates',
]);
// Check that both files were deleted (new file due to --delete, duplicate due to --delete-duplicates)
const files = await readdir(`/tmp/albums/nature`);
await rm(`/tmp/albums/nature`, { recursive: true });
expect(files.length).toBe(0);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Found 1 new files and 1 duplicate'),
expect.stringContaining('Successfully uploaded 1 new asset'),
expect.stringContaining('Deleting assets that have been uploaded'),
]),
);
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
// Verify one new asset was uploaded (total should be 2 now)
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
expect(assets.total).toBe(2);
});
it('should only delete duplicates when --delete-duplicates is used without --delete', async () => {
const {
stderr: firstStderr,
stdout: firstStdout,
exitCode: firstExitCode,
} = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(firstStderr).toContain('{message}');
expect(firstStdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
expect(firstExitCode).toBe(0);
// Both new and duplicate files
await mkdir(`/tmp/albums/nature`, { recursive: true });
await symlink(`${testAssetDir}/albums/nature/silver_fir.jpg`, `/tmp/albums/nature/silver_fir.jpg`); // duplicate
await symlink(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `/tmp/albums/nature/el_torcal_rocks.jpg`); // new
// Upload with only --delete-duplicates flag
const { stderr, stdout, exitCode } = await immichCli(['upload', `/tmp/albums/nature`, '--delete-duplicates']);
// Check that only the duplicate was deleted, new file should remain
const files = await readdir(`/tmp/albums/nature`);
await rm(`/tmp/albums/nature`, { recursive: true });
expect(files).toEqual(['el_torcal_rocks.jpg']);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Found 1 new files and 1 duplicate'),
expect.stringContaining('Successfully uploaded 1 new asset'),
]),
);
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
// Verify one new asset was uploaded (total should be 2 now)
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
expect(assets.total).toBe(2);
});
});
describe('immich upload --skip-hash', () => {
it('should skip hashing', async () => {
const filename = `albums/nature/silver_fir.jpg`;

View File

@@ -1,178 +0,0 @@
#!/usr/bin/env node
/**
* Script to generate test images with additional EXIF date tags
* This creates actual JPEG images with embedded metadata for testing
* Images are generated into e2e/test-assets/metadata/dates/
*/
import { execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
interface TestImage {
filename: string;
description: string;
exifTags: Record<string, string>;
}
const testImages: TestImage[] = [
{
filename: 'time-created.jpg',
description: 'Image with TimeCreated tag',
exifTags: {
TimeCreated: '2023:11:15 14:30:00',
Make: 'Canon',
Model: 'EOS R5',
},
},
{
filename: 'gps-datetime.jpg',
description: 'Image with GPSDateTime and coordinates',
exifTags: {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: '37.7749',
GPSLongitude: '-122.4194',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'datetime-utc.jpg',
description: 'Image with DateTimeUTC tag',
exifTags: {
DateTimeUTC: '2023:11:15 10:30:00',
Make: 'Nikon',
Model: 'D850',
},
},
{
filename: 'gps-datestamp.jpg',
description: 'Image with GPSDateStamp and GPSTimeStamp',
exifTags: {
GPSDateStamp: '2023:11:15',
GPSTimeStamp: '08:30:00',
GPSLatitude: '51.5074',
GPSLongitude: '-0.1278',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'sony-datetime2.jpg',
description: 'Sony camera image with SonyDateTime2 tag',
exifTags: {
SonyDateTime2: '2023:11:15 06:30:00',
Make: 'SONY',
Model: 'ILCE-7RM5',
},
},
{
filename: 'date-priority-test.jpg',
description: 'Image with multiple date tags to test priority',
exifTags: {
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
DateTimeOriginal: '2023:02:02 02:00:00',
SubSecCreateDate: '2023:03:03 03:00:00',
CreateDate: '2023:04:04 04:00:00',
CreationDate: '2023:05:05 05:00:00',
DateTimeCreated: '2023:06:06 06:00:00',
TimeCreated: '2023:07:07 07:00:00',
GPSDateTime: '2023:08:08 08:00:00',
DateTimeUTC: '2023:09:09 09:00:00',
GPSDateStamp: '2023:10:10',
SonyDateTime2: '2023:11:11 11:00:00',
},
},
{
filename: 'new-tags-only.jpg',
description: 'Image with only additional date tags (no standard tags)',
exifTags: {
TimeCreated: '2023:12:01 15:45:30',
GPSDateTime: '2023:12:01 13:45:30Z',
DateTimeUTC: '2023:12:01 13:45:30',
GPSDateStamp: '2023:12:01',
SonyDateTime2: '2023:12:01 08:45:30',
GPSLatitude: '40.7128',
GPSLongitude: '-74.0060',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
];
const generateTestImages = async (): Promise<void> => {
// Target directory: e2e/test-assets/metadata/dates/
// Current file is in: e2e/src/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
console.log('Generating test images with additional EXIF date tags...');
console.log(`Target directory: ${targetDir}`);
for (const image of testImages) {
try {
const imagePath = join(targetDir, image.filename);
// Create unique JPEG file using Sharp
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
const jpegData = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
writeFileSync(imagePath, jpegData);
// Build exiftool command to add EXIF data
const exifArgs = Object.entries(image.exifTags)
.map(([tag, value]) => `-${tag}="${value}"`)
.join(' ');
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
console.log(`Creating ${image.filename}: ${image.description}`);
execSync(command, { stdio: 'pipe' });
// Verify the tags were written
const verifyCommand = `exiftool -json "${imagePath}"`;
const result = execSync(verifyCommand, { encoding: 'utf8' });
const metadata = JSON.parse(result)[0];
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
// Log first date tag found for verification
const firstDateTag = Object.keys(image.exifTags).find(
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
);
if (firstDateTag && metadata[firstDateTag]) {
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
}
} catch (error) {
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
}
}
console.log('\nTest image generation complete!');
console.log('Files created in:', targetDir);
console.log('\nTo test these images:');
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
};
export { generateTestImages };
// Run the generator if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
generateTestImages().catch(console.error);
}

View File

@@ -0,0 +1,37 @@
export { generateTimelineData } from './timeline/model-objects';
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
export type {
MockAlbum,
MonthSpec,
SerializedTimelineData,
MockTimelineAsset as TimelineAssetConfig,
TimelineConfig,
MockTimelineData as TimelineData,
} from './timeline/timeline-config';
export {
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
toAssetResponseDto,
toColumnarFormat,
} from './timeline/rest-response';
export type { Changes } from './timeline/rest-response';
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
export {
SeededRandom,
getMockAsset,
parseTimeBucketKey,
selectRandom,
selectRandomDays,
selectRandomMultiple,
} from './timeline/utils';
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';

View File

@@ -0,0 +1,183 @@
import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
import type { MockTimelineAsset } from './timeline-config';
import { GENERATION_CONSTANTS } from './timeline-config';
type AssetDistributionStrategy = (rng: SeededRandom) => number;
type DayDistributionStrategy = (
year: number,
month: number,
daysInMonth: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
) => MockTimelineAsset[];
/**
* Strategies for determining total asset count per month
*/
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
empty: null, // Special case - handled separately
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
};
/**
* Strategies for distributing assets across days within a month
*/
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// All assets on one day in the middle of the month
const day = Math.floor(daysInMonth / 2);
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
},
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// 3-5 consecutive days with evenly distributed assets
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
},
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
let assetIndex = 0;
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
const dayAssets = Math.min(3, rng.nextInt(1, 4));
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
}
return assets;
},
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Alternate between large (15-25) and small (1-3) days
const assets: MockTimelineAsset[] = [];
let day = 1;
let isLarge = true;
let assetIndex = 0;
while (assetIndex < totalAssets && day <= daysInMonth) {
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
day += isLarge ? 1 : 1; // Could add gaps here
isLarge = !isLarge;
}
return assets;
},
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Spread assets across random days with gaps
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
let assetIndex = 0;
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
const dayAssets =
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
assetIndex += dayAssets;
}
return assets;
},
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in first week
const assets: MockTimelineAsset[] = [];
const firstWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - firstWeekAssets;
// First 7 days
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
// Remaining scattered
if (remainingAssets > 0) {
const midDay = Math.floor(daysInMonth / 2);
// Create a new RNG for the remaining assets
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
}
return assets;
},
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in last week
const assets: MockTimelineAsset[] = [];
const lastWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - lastWeekAssets;
// Remaining at start
if (remainingAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
}
// Last 7 days
const startDay = daysInMonth - 6;
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
return assets;
},
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in middle of month
const assets: MockTimelineAsset[] = [];
const midAssets = Math.floor(totalAssets * 0.7);
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
// Start
if (sideAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
}
// Middle
const midStart = Math.floor(daysInMonth / 2) - 3;
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
// End
const endAssets = totalAssets - midAssets - sideAssets;
if (endAssets > 0) {
// Create a new RNG for the end assets
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
}
return assets;
},
};
export type MonthDistribution =
| 'empty' // 0 assets
| 'sparse' // 3-8 assets
| 'medium' // 15-30 assets
| 'dense' // 50-80 assets
| 'very-dense'; // 80-150 assets
export type DayPattern =
| 'single-day' // All images in one day
| 'consecutive-large' // Multiple days with 15-25 images each
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
| 'alternating' // Alternating large/small days
| 'sparse-scattered' // Few images scattered across month
| 'start-heavy' // Most images at start of month
| 'end-heavy' // Most images at end of month
| 'mid-heavy'; // Most images in middle of month

View File

@@ -0,0 +1,111 @@
import sharp from 'sharp';
import { SeededRandom } from 'src/generators/timeline/utils';
export const randomThumbnail = async (seed: string, ratio: number) => {
const height = 235;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomPreview = async (seed: string, ratio: number) => {
const height = 500;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomImageFromString = async (
seed: string = '',
{ width = 100, height = 100 }: { width: number; height: number },
) => {
// Convert string to number for seeding
let seedNumber = 0;
for (let i = 0; i < seed.length; i++) {
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
}
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
};
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
const r1 = rng.nextInt(0, 256);
const g1 = rng.nextInt(0, 256);
const b1 = rng.nextInt(0, 256);
const r2 = rng.nextInt(0, 256);
const g2 = rng.nextInt(0, 256);
const b2 = rng.nextInt(0, 256);
const patternType = rng.nextInt(0, 5);
let svgPattern = '';
switch (patternType) {
case 0: {
// Solid color
svgPattern = `<svg width="${width}" height="${height}">
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
</svg>`;
break;
}
case 1: {
// Horizontal stripes
const stripeHeight = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: height / stripeHeight },
(_, i) =>
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 2: {
// Vertical stripes
const stripeWidth = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: width / stripeWidth },
(_, i) =>
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 3: {
// Checkerboard
const squareSize = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from({ length: height / squareSize }, (_, row) =>
Array.from({ length: width / squareSize }, (_, col) => {
const isEven = (row + col) % 2 === 0;
return `<rect x="${col * squareSize}" y="${row * squareSize}"
width="${squareSize}" height="${squareSize}"
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
}).join(''),
).join('')}
</svg>`;
break;
}
case 4: {
// Diagonal stripes
svgPattern = `<svg width="${width}" height="${height}">
<defs>
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
</pattern>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
</svg>`;
break;
}
}
const svgBuffer = Buffer.from(svgPattern);
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
return jpegData;
};

View File

@@ -0,0 +1,265 @@
/**
* Generator functions for timeline model objects
*/
import { faker } from '@faker-js/faker';
import { AssetVisibility } from '@immich/sdk';
import { DateTime } from 'luxon';
import { writeFileSync } from 'node:fs';
import { SeededRandom } from 'src/generators/timeline/utils';
import type { DayPattern, MonthDistribution } from './distribution-patterns';
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
/**
* Generate a random aspect ratio based on weighted probabilities
*/
export function generateAspectRatio(rng: SeededRandom): string {
const random = rng.next();
let cumulative = 0;
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
cumulative += weight;
if (random < cumulative) {
return ratio;
}
}
return '16:9'; // Default fallback
}
export function generateThumbhash(rng: SeededRandom): string {
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
}
export function generateDuration(rng: SeededRandom): string {
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
}
export function generateUUID(): string {
return faker.string.uuid();
}
export function generateAsset(
year: number,
month: number,
day: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset {
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
const to = from.endOf('day');
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
const assetId = generateUUID();
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
const ratio = generateAspectRatio(rng);
const asset: MockTimelineAsset = {
id: assetId,
ownerId,
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
thumbhash: generateThumbhash(rng),
localDateTime: date.toISOString(),
fileCreatedAt: date.toISOString(),
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
isTrashed: false,
isVideo,
isImage: !isVideo,
duration: isVideo ? generateDuration(rng) : null,
projectionType: null,
livePhotoVideoId: null,
city: hasGPS ? faker.location.city() : null,
country: hasGPS ? faker.location.country() : null,
people: null,
latitude: hasGPS ? faker.location.latitude() : null,
longitude: hasGPS ? faker.location.longitude() : null,
visibility: AssetVisibility.Timeline,
stack: null,
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
checksum: faker.string.alphanumeric({ length: 5 }),
};
return asset;
}
/**
* Generate assets for a specific day
*/
export function generateDayAssets(
year: number,
month: number,
day: number,
assetCount: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
}
/**
* Distribute assets evenly across consecutive days
*
* @returns Array of generated timeline assets
*/
export function generateConsecutiveDays(
year: number,
month: number,
startDay: number,
numDays: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
const assets: MockTimelineAsset[] = [];
const assetsPerDay = Math.floor(totalAssets / numDays);
for (let i = 0; i < numDays; i++) {
const dayAssets =
i === numDays - 1
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
: assetsPerDay;
// Create a new RNG with a different seed for each day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
}
return assets;
}
/**
* Generate assets for a month with specified distribution pattern
*/
export function generateMonthAssets(
year: number,
month: number,
ownerId: string,
distribution: MonthDistribution = 'medium',
pattern: DayPattern = 'consecutive-large',
rng: SeededRandom,
): MockTimelineAsset[] {
const daysInMonth = new Date(year, month, 0).getDate();
if (distribution === 'empty') {
return [];
}
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
if (!distributionStrategy) {
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
return [];
}
const totalAssets = distributionStrategy(rng);
const dayStrategy = DAY_DISTRIBUTION[pattern];
if (!dayStrategy) {
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
// Fallback to consecutive-large pattern
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
/**
* Main generator function for timeline data
*/
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
validateTimelineConfig(config);
const buckets = new Map<string, MockTimelineAsset[]>();
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
faker.seed(globalRng.nextInt(0, 1_000_000));
for (const monthConfig of config.months) {
const { year, month, distribution, pattern } = monthConfig;
const monthSeed = globalRng.nextInt(0, 1_000_000);
const monthRng = new SeededRandom(monthSeed);
const monthAssets = generateMonthAssets(
year,
month,
config.ownerId || generateUUID(),
distribution,
pattern,
monthRng,
);
if (monthAssets.length > 0) {
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
monthStats[monthKey] = {
count: monthAssets.length,
distribution,
pattern,
};
// Create bucket key (YYYY-MM-01)
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
buckets.set(bucketKey, monthAssets);
}
}
// Create a mock album from random assets
const allAssets = [...buckets.values()].flat();
// Select 10-30 random assets for the album (or all assets if less than 10)
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
const selectedAssetConfigs: MockTimelineAsset[] = [];
const usedIndices = new Set<number>();
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
const randomIndex = globalRng.nextInt(0, allAssets.length);
if (!usedIndices.has(randomIndex)) {
usedIndices.add(randomIndex);
selectedAssetConfigs.push(allAssets[randomIndex]);
}
}
// Sort selected assets by date (newest first)
selectedAssetConfigs.sort(
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
);
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
const now = new Date().toISOString();
const album = {
id: generateUUID(),
albumName: 'Test Album',
description: 'A mock album for testing',
assetIds: selectedAssets,
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
createdAt: now,
updatedAt: now,
};
// Write to file if configured
if (config.writeToFile) {
const outputPath = config.outputPath || '/tmp/timeline-data.json';
// Convert Map to object for serialization
const serializedData: SerializedTimelineData = {
buckets: Object.fromEntries(buckets),
album,
};
try {
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
console.log(`Timeline data written to ${outputPath}`);
} catch (error) {
console.error(`Failed to write timeline data to ${outputPath}:`, error);
}
}
return { buckets, album };
}

View File

@@ -0,0 +1,436 @@
/**
* REST API output functions for converting timeline data to API response formats
*/
import {
AssetTypeEnum,
AssetVisibility,
UserAvatarColor,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { signupDto } from 'src/fixtures';
import { parseTimeBucketKey } from 'src/generators/timeline/utils';
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
/**
* Convert timeline/asset models to columnar format (parallel arrays)
*/
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
const result: TimeBucketAssetResponseDto = {
id: [],
ownerId: [],
ratio: [],
thumbhash: [],
fileCreatedAt: [],
localOffsetHours: [],
isFavorite: [],
isTrashed: [],
isImage: [],
duration: [],
projectionType: [],
livePhotoVideoId: [],
city: [],
country: [],
visibility: [],
};
for (const asset of assets) {
result.id.push(asset.id);
result.ownerId.push(asset.ownerId);
result.ratio.push(asset.ratio);
result.thumbhash.push(asset.thumbhash);
result.fileCreatedAt.push(asset.fileCreatedAt);
result.localOffsetHours.push(0); // Assuming UTC for mocks
result.isFavorite.push(asset.isFavorite);
result.isTrashed.push(asset.isTrashed);
result.isImage.push(asset.isImage);
result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city.push(asset.city);
result.country.push(asset.country);
result.visibility.push(asset.visibility);
}
if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
result.latitude = assets.map((a) => a.latitude);
result.longitude = assets.map((a) => a.longitude);
}
result.stack = assets.map(() => null);
return result;
}
/**
* Extract a single bucket from timeline data (mimics getTimeBucket API)
* Automatically handles both ISO timestamp and simple month formats
* Returns data in columnar format matching the actual API
* When albumId is provided, only returns assets from that album
*/
export function getTimeBucket(
timelineData: MockTimelineData,
timeBucket: string,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketAssetResponseDto {
const bucketKey = parseTimeBucketKey(timeBucket);
let assets = timelineData.buckets.get(bucketKey);
if (!assets) {
return toColumnarFormat([]);
}
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// Filter assets based on trashed/archived status
assets = assets.filter((asset) =>
shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
);
// Filter to only include assets from the specified album
if (albumId) {
const album = timelineData.album;
if (!album || album.id !== albumId) {
return toColumnarFormat([]);
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
assets = assets.filter((asset) => albumAssetIds.has(asset.id));
}
// Override properties for assets in changes arrays
const assetsWithOverrides = assets.map((asset) => {
if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
return {
...asset,
isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
};
}
return asset;
});
return toColumnarFormat(assetsWithOverrides);
}
export type Changes = {
// ids of assets that are newly added to the album
albumAdditions: string[];
// ids of assets that are newly deleted
assetDeletions: string[];
// ids of assets that are newly archived
assetArchivals: string[];
// ids of assets that are newly favorited
assetFavorites: string[];
};
/**
* Helper function to determine if an asset should be included based on filter criteria
* @param asset - The asset to check
* @param isTrashed - Filter for trashed status (undefined means no filter)
* @param isArchived - Filter for archived status (undefined means no filter)
* @param isFavorite - Filter for favorite status (undefined means no filter)
* @param deletedAssetIds - Set of IDs for assets that have been deleted
* @param archivedAssetIds - Set of IDs for assets that have been archived
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
* @returns true if the asset matches all filter criteria
*/
function shouldIncludeAsset(
asset: MockTimelineAsset,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
deletedAssetIds: Set<string>,
archivedAssetIds: Set<string>,
favoritedAssetIds: Set<string>,
): boolean {
// Determine actual status (property or in changes)
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
// Apply filters
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
return false;
}
if (isArchived !== undefined && actuallyArchived !== isArchived) {
return false;
}
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
return false;
}
return true;
}
/**
* Get summary for all buckets (mimics getTimeBuckets API)
* When albumId is provided, only includes buckets that contain assets from that album
*/
export function getTimeBuckets(
timelineData: MockTimelineData,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketsResponseDto[] {
const summary: TimeBucketsResponseDto[] = [];
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// If no albumId is specified, return summary for all assets
if (albumId) {
// Filter to only include buckets with assets from the specified album
const album = timelineData.album;
if (!album || album.id !== albumId) {
return [];
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
for (const removed of changes.assetDeletions) {
albumAssetIds.delete(removed);
}
for (const [bucketKey, assets] of timelineData.buckets) {
// Count how many assets in this bucket are in the album and match trashed/archived filters
const albumAssetsInBucket = assets.filter((asset) => {
// Must be in the album
if (!albumAssetIds.has(asset.id)) {
return false;
}
return shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
);
});
if (albumAssetsInBucket.length > 0) {
summary.push({
timeBucket: bucketKey,
count: albumAssetsInBucket.length,
});
}
}
} else {
for (const [bucketKey, assets] of timelineData.buckets) {
// Filter assets based on trashed/archived status
const filteredAssets = assets.filter((asset) =>
shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
),
);
if (filteredAssets.length > 0) {
summary.push({
timeBucket: bucketKey,
count: filteredAssets.length,
});
}
}
}
// Sort summary by date (newest first) using luxon
summary.sort((a, b) => {
const dateA = DateTime.fromISO(a.timeBucket);
const dateB = DateTime.fromISO(b.timeBucket);
return dateB.diff(dateA).milliseconds;
});
return summary;
}
const createDefaultOwner = (ownerId: string) => {
const defaultOwner: UserResponseDto = {
id: ownerId,
email: signupDto.admin.email,
name: signupDto.admin.name,
profileImagePath: '',
profileChangedAt: new Date().toISOString(),
avatarColor: UserAvatarColor.Blue,
};
return defaultOwner;
};
/**
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
const now = new Date().toISOString();
// Default owner if not provided
const defaultOwner = createDefaultOwner(asset.ownerId);
const exifInfo: ExifResponseDto = {
make: null,
model: null,
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
fileSizeInByte: asset.fileSizeInByte,
orientation: '1',
dateTimeOriginal: asset.fileCreatedAt,
modifyDate: asset.fileCreatedAt,
timeZone: asset.latitude === null ? null : 'UTC',
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: asset.latitude,
longitude: asset.longitude,
city: asset.city,
country: asset.country,
state: null,
description: null,
};
return {
id: asset.id,
deviceAssetId: `device-${asset.id}`,
ownerId: asset.ownerId,
owner: owner || defaultOwner,
libraryId: `library-${asset.ownerId}`,
deviceId: `device-${asset.ownerId}`,
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
updatedAt: now,
createdAt: asset.fileCreatedAt,
isFavorite: asset.isFavorite,
isArchived: false,
isTrashed: asset.isTrashed,
visibility: asset.visibility,
duration: asset.duration || '0:00:00.00000',
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: asset.checksum,
};
}
/**
* Get a single asset by ID from timeline data
* This matches the response from GET /api/assets/:id
*/
export function getAsset(
timelineData: MockTimelineData,
assetId: string,
owner?: UserResponseDto,
): AssetResponseDto | undefined {
// Search through all buckets for the asset
const buckets = [...timelineData.buckets.values()];
for (const assets of buckets) {
const asset = assets.find((a) => a.id === assetId);
if (asset) {
return toAssetResponseDto(asset, owner);
}
}
return undefined;
}
/**
* Get a mock album from timeline data
* This matches the response from GET /api/albums/:id
*/
export function getAlbum(
timelineData: MockTimelineData,
ownerId: string,
albumId: string | undefined,
changes: Changes,
): AlbumResponseDto | undefined {
if (!timelineData.album) {
return undefined;
}
// If albumId is provided and doesn't match, return undefined
if (albumId && albumId !== timelineData.album.id) {
return undefined;
}
const album = timelineData.album;
const albumOwner = createDefaultOwner(ownerId);
// Get the actual asset objects from the timeline data
const albumAssets: AssetResponseDto[] = [];
const allAssets = [...timelineData.buckets.values()].flat();
for (const assetId of album.assetIds) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
for (const assetId of changes.albumAdditions ?? []) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
// For a basic mock album, we don't include any albumUsers (shared users)
// The owner is represented by the owner field, not in albumUsers
const response: AlbumResponseDto = {
id: album.id,
albumName: album.albumName,
description: album.description,
albumThumbnailAssetId: album.thumbnailAssetId,
createdAt: album.createdAt,
updatedAt: album.updatedAt,
ownerId: albumOwner.id,
owner: albumOwner,
albumUsers: [], // Empty array for non-shared album
shared: false,
hasSharedLink: false,
isActivityEnabled: true,
assetCount: albumAssets.length,
assets: albumAssets,
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
};
return response;
}

View File

@@ -0,0 +1,200 @@
import type { AssetVisibility } from '@immich/sdk';
import { DayPattern, MonthDistribution } from 'src/generators/timeline/distribution-patterns';
// Constants for generation parameters
export const GENERATION_CONSTANTS = {
VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
MIN_VIDEO_DURATION_SECONDS: 5,
MAX_VIDEO_DURATION_SECONDS: 300,
DEFAULT_SEED: 12_345,
DEFAULT_OWNER_ID: 'user-1',
MAX_SELECT_ATTEMPTS: 10,
SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
} as const;
// Aspect ratio distribution weights (must sum to 1)
export const ASPECT_RATIO_WEIGHTS = {
'4:3': 0.35, // 35% 4:3 landscape
'3:2': 0.25, // 25% 3:2 landscape
'16:9': 0.2, // 20% 16:9 landscape
'2:3': 0.1, // 10% 2:3 portrait
'1:1': 0.09, // 9% 1:1 square
'3:1': 0.01, // 1% 3:1 panorama
} as const;
export type AspectRatio = {
width: number;
height: number;
ratio: number;
name: string;
};
// Mock configuration for asset generation - will be transformed to API response formats
export type MockTimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: string;
fileCreatedAt: string;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
country: string | null;
people: string[] | null;
latitude: number | null;
longitude: number | null;
visibility: AssetVisibility;
stack: null;
checksum: string;
fileSizeInByte: number;
};
export type MonthSpec = {
year: number;
month: number; // 1-12
distribution: MonthDistribution;
pattern: DayPattern;
};
/**
* Configuration for timeline data generation
*/
export type TimelineConfig = {
ownerId?: string;
months: MonthSpec[];
seed?: number;
writeToFile?: boolean;
outputPath?: string;
};
export type MockAlbum = {
id: string;
albumName: string;
description: string;
assetIds: string[]; // IDs of assets in the album
thumbnailAssetId: string | null;
createdAt: string;
updatedAt: string;
};
export type MockTimelineData = {
buckets: Map<string, MockTimelineAsset[]>;
album: MockAlbum; // Mock album created from random assets
};
export type SerializedTimelineData = {
buckets: Record<string, MockTimelineAsset[]>;
album: MockAlbum;
};
/**
* Validates a TimelineConfig object to ensure all values are within expected ranges
*/
export function validateTimelineConfig(config: TimelineConfig): void {
if (!config.months || config.months.length === 0) {
throw new Error('TimelineConfig must contain at least one month');
}
const seenMonths = new Set<string>();
for (const month of config.months) {
if (month.month < 1 || month.month > 12) {
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
}
if (month.year < 1900 || month.year > 2100) {
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
}
const monthKey = `${month.year}-${month.month}`;
if (seenMonths.has(monthKey)) {
throw new Error(`Duplicate month found: ${monthKey}`);
}
seenMonths.add(monthKey);
// Validate distribution if provided
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
throw new Error(
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
);
}
const validPatterns = [
'single-day',
'consecutive-large',
'consecutive-small',
'alternating',
'sparse-scattered',
'start-heavy',
'end-heavy',
'mid-heavy',
];
if (month.pattern && !validPatterns.includes(month.pattern)) {
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
}
}
// Validate seed if provided
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
throw new Error('Seed must be a non-negative integer');
}
// Validate ownerId if provided
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
throw new Error('Owner ID cannot be an empty string');
}
}
/**
* Create a default timeline configuration
*/
export function createDefaultTimelineConfig(): TimelineConfig {
const months: MonthSpec[] = [
// 2024 - Mix of patterns
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
// 2023 - Testing year boundaries and more patterns
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
];
for (let year = 2022; year >= 2000; year--) {
for (let month = 12; month >= 1; month--) {
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
}
}
return {
months,
seed: 42,
};
}

View File

@@ -0,0 +1,186 @@
import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/generators/timeline/timeline-config';
/**
* Linear Congruential Generator for deterministic pseudo-random numbers
*/
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
/**
* Generate next random number in range [0, 1)
*/
next(): number {
// LCG parameters from Numerical Recipes
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
return this.seed / 2_147_483_647;
}
/**
* Generate random integer in range [min, max)
*/
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
/**
* Generate random boolean with given probability
*/
nextBoolean(probability = 0.5): boolean {
return this.next() < probability;
}
}
/**
* Select random days using seed variation to avoid collisions.
*
* @param daysInMonth - Total number of days in the month
* @param numDays - Number of days to select
* @param rng - Random number generator instance
* @returns Array of selected day numbers, sorted in descending order
*/
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
const selectedDays = new Set<number>();
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
let attempts = 0;
while (selectedDays.size < numDays && attempts < maxAttempts) {
const day = rng.nextInt(1, daysInMonth + 1);
selectedDays.add(day);
attempts++;
}
// Fallback: if we couldn't select enough random days, fill with sequential days
if (selectedDays.size < numDays) {
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
selectedDays.add(day);
}
}
return [...selectedDays].toSorted((a, b) => b - a);
}
/**
* Select item from array using seeded random
*/
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
const index = rng.nextInt(0, arr.length);
return arr[index];
}
/**
* Select multiple random items from array using seeded random without duplicates
*/
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
if (count < 0) {
throw new Error('Count must be non-negative');
}
if (count > arr.length) {
throw new Error('Count cannot exceed array length');
}
const result: T[] = [];
const selectedIndices = new Set<number>();
while (result.length < count) {
const index = rng.nextInt(0, arr.length);
if (!selectedIndices.has(index)) {
selectedIndices.add(index);
result.push(arr[index]);
}
}
return result;
}
/**
* Parse timeBucket parameter to extract year-month key
* Handles both formats:
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
* - Simple format: "2024-12-01" -> "2024-12-01"
*/
export function parseTimeBucketKey(timeBucket: string): string {
if (!timeBucket) {
throw new Error('timeBucket parameter cannot be empty');
}
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
if (!dt.isValid) {
// Fallback to regex if not a valid ISO string
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : timeBucket;
}
// Format as YYYY-MM-01 (first day of month)
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
}
export function getMockAsset(
asset: MockTimelineAsset,
sortedDescendingAssets: MockTimelineAsset[],
direction: 'next' | 'previous',
unit: 'day' | 'month' | 'year' = 'day',
): MockTimelineAsset | null {
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
if (currentIndex === -1) {
return null;
}
const step = direction === 'next' ? 1 : -1;
const startIndex = currentIndex + step;
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
return null;
}
if (direction === 'previous' && currentIndex <= 0) {
return null;
}
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
} else if (unit === 'month') {
return date1.year !== date2.year || date1.month !== date2.month;
} else {
return date1.year !== date2.year;
}
};
if (direction === 'next') {
// Search forward in array (backwards in time)
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
const nextAsset = sortedDescendingAssets[i];
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(nextDate, currentDateTime)) {
return nextAsset;
}
}
} else {
// Search backward in array (forwards in time)
for (let i = startIndex; i >= 0; i--) {
const prevAsset = sortedDescendingAssets[i];
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(prevDate, currentDateTime)) {
return prevAsset;
}
}
}
return null;
}

View File

@@ -0,0 +1,285 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'playwright.config';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
{
name: 'immich_is_authenticated',
value: 'true',
domain: playwrightHost,
path: '/',
},
]);
await context.route('**/api/users/me', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
id: adminUserId,
email: 'admin@immich.cloud',
name: 'Immich Admin',
profileImagePath: '',
avatarColor: 'orange',
profileChangedAt: '2025-01-22T21:31:23.996Z',
storageLabel: 'admin',
shouldChangePassword: true,
isAdmin: true,
createdAt: '2025-01-22T21:31:23.996Z',
deletedAt: null,
updatedAt: '2025-11-14T00:00:00.369Z',
oauthId: '',
quotaSizeInBytes: null,
quotaUsageInBytes: 20_849_000_159,
status: 'active',
license: null,
},
});
});
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: {
defaultAssetOrder: 'desc',
},
folders: {
enabled: false,
sidebarWeb: false,
},
memories: {
enabled: true,
duration: 5,
},
people: {
enabled: true,
sidebarWeb: false,
},
sharedLinks: {
enabled: true,
sidebarWeb: false,
},
ratings: {
enabled: false,
},
tags: {
enabled: false,
sidebarWeb: false,
},
emailNotifications: {
enabled: true,
albumInvite: true,
albumUpdate: true,
},
download: {
archiveSize: 4_294_967_296,
includeEmbeddedVideos: false,
},
purchase: {
showSupportBadge: true,
hideBuyButtonUntil: '2100-02-12T00:00:00.000Z',
},
cast: {
gCastEnabled: false,
},
},
});
});
await context.route('**/server/about', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
version: 'v2.2.3',
versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
licensed: false,
build: '1234567890',
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
buildImage: 'e2e',
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
repository: 'immich-app/immich',
repositoryUrl: 'https://github.com/immich-app/immich',
sourceRef: 'e2e',
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
nodejs: 'v22.18.0',
exiftool: '13.41',
ffmpeg: '7.1.1-6',
libvips: '8.17.2',
imagemagick: '7.1.2-2',
},
});
});
await context.route('**/api/server/features', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
smartSearch: false,
facialRecognition: false,
duplicateDetection: false,
map: true,
reverseGeocoding: true,
importFaces: false,
sidecar: true,
search: true,
trash: true,
oauth: false,
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
configFile: false,
email: false,
},
});
});
await context.route('**/api/server/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
loginPageMessage: '',
trashDays: 30,
userDeleteDelay: 7,
oauthButtonText: 'Login with OAuth',
isInitialized: true,
isOnboarded: true,
externalDomain: '',
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
},
});
});
await context.route('**/api/server/media-types', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
video: [
'.3gp',
'.3gpp',
'.avi',
'.flv',
'.insv',
'.m2t',
'.m2ts',
'.m4v',
'.mkv',
'.mov',
'.mp4',
'.mpe',
'.mpeg',
'.mpg',
'.mts',
'.vob',
'.webm',
'.wmv',
],
image: [
'.3fr',
'.ari',
'.arw',
'.cap',
'.cin',
'.cr2',
'.cr3',
'.crw',
'.dcr',
'.dng',
'.erf',
'.fff',
'.iiq',
'.k25',
'.kdc',
'.mrw',
'.nef',
'.nrw',
'.orf',
'.ori',
'.pef',
'.psd',
'.raf',
'.raw',
'.rw2',
'.rwl',
'.sr2',
'.srf',
'.srw',
'.x3f',
'.avif',
'.gif',
'.jpeg',
'.jpg',
'.png',
'.webp',
'.bmp',
'.heic',
'.heif',
'.hif',
'.insp',
'.jp2',
'.jpe',
'.jxl',
'.svg',
'.tif',
'.tiff',
],
sidecar: ['.xmp'],
},
});
});
await context.route('**/api/notifications*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/albums*', async (route, request) => {
if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
}
await route.fallback();
});
await context.route('**/api/memories*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/server/storage', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
diskSize: '100.0 GiB',
diskUse: '74.4 GiB',
diskAvailable: '25.6 GiB',
diskSizeRaw: 107_374_182_400,
diskUseRaw: 79_891_660_800,
diskAvailableRaw: 27_482_521_600,
diskUsagePercentage: 74.4,
},
});
});
await context.route('**/api/server/version-history', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [
{
id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
createdAt: '2025-11-15T20:14:01.935Z',
version: '2.2.3',
},
],
});
});
};

View File

@@ -0,0 +1,149 @@
import { BrowserContext, Page, Request, Route } from '@playwright/test';
import { basename } from 'node:path';
import {
Changes,
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
randomPreview,
randomThumbnail,
TimelineData,
} from 'src/generators/timeline';
import { sleep } from 'src/web/specs/timeline/utils';
export class TimelineTestContext {
slowBucket = false;
adminId = '';
}
export const setupTimelineMockApiRoutes = async (
context: BrowserContext,
timelineRestData: TimelineData,
changes: Changes,
testContext: TimelineTestContext,
) => {
await context.route('**/api/timeline**', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/timeline/buckets') {
const albumId = url.searchParams.get('albumId') || undefined;
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
return route.fulfill({
status: 200,
contentType: 'application/json',
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
});
} else if (pathname === '/api/timeline/bucket') {
const timeBucket = url.searchParams.get('timeBucket');
if (!timeBucket) {
return route.continue();
}
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const albumId = url.searchParams.get('albumId') || undefined;
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
if (testContext.slowBucket) {
await sleep(5000);
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assets,
});
}
return route.continue();
});
await context.route('**/api/assets/*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
});
await context.route('**/api/assets/*/ocr', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
}
if (match.groups.size === 'preview') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
body: await randomPreview(
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
if (match.groups.size === 'thumbnail') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail(
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
return route.continue();
});
await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern);
if (!match) {
return route.continue();
}
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
});
};
export const pageRoutePromise = async (
page: Page,
route: string,
callback: (route: Route, request: Request) => Promise<void>,
) => {
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
const deleteRequest = new Promise((resolve) => {
resolveRequest = resolve;
});
await page.route(route, async (route, request) => {
await callback(route, request);
const requestJson = request.postDataJSON();
resolveRequest?.(requestJson);
});
return deleteRequest;
};

View File

@@ -7,6 +7,12 @@ export const errorDto = {
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,
@@ -119,5 +125,6 @@ export const deviceDto = {
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
appVersion: null,
},
};

View File

@@ -1,5 +1,4 @@
import {
AllJobStatusResponseDto,
AssetMediaCreateDto,
AssetMediaResponseDto,
AssetResponseDto,
@@ -7,11 +6,13 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
JobCommandDto,
JobName,
MaintenanceAction,
MetadataSearchDto,
Permission,
PersonCreateDto,
QueueCommandDto,
QueueName,
QueuesResponseLegacyDto,
SharedLinkCreateDto,
UpdateLibraryDto,
UserAdminCreateDto,
@@ -27,15 +28,16 @@ import {
createStack,
createUserAdmin,
deleteAssets,
getAllJobsStatus,
getAssetInfo,
getConfig,
getConfigDefaults,
getQueuesLegacy,
login,
runQueueCommandLegacy,
scanLibrary,
searchAssets,
sendJobCommand,
setBaseUrl,
setMaintenanceMode,
signUpAdmin,
tagAssets,
updateAdminOnboarding,
@@ -52,7 +54,7 @@ import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path, { dirname } from 'node:path';
import { dirname, resolve } from 'node:path';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util';
import pg from 'pg';
@@ -60,6 +62,8 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
export type { Emitter } from '@socket.io/component-emitter';
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
@@ -68,12 +72,12 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
type AdminSetupOptions = { onboarding?: boolean };
type FileData = { bytes?: Buffer; filename: string };
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
export const baseUrl = 'http://127.0.0.1:2285';
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
export const baseUrl = playwriteBaseUrl;
export const shareUrl = `${baseUrl}/share`;
export const app = `${baseUrl}/api`;
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve('./test-assets');
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
export const testAssetDirInternal = '/test-assets';
export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
@@ -477,10 +481,10 @@ export const utils = {
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
await context.addCookies([
{
name: 'immich_access_token',
@@ -514,6 +518,42 @@ export const utils = {
},
]),
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
await context.addCookies([
{
name: 'immich_maintenance_token',
value: token,
domain,
path: '/',
expires: 2_058_028_213,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
]),
enterMaintenance: async (accessToken: string) => {
let setCookie: string[] | undefined;
await setMaintenanceMode(
{
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
},
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
}),
},
);
return setCookie;
},
resetTempFolder: () => {
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
@@ -524,13 +564,13 @@ export const utils = {
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
},
isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) });
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
const jobCounts = queues[queue].jobCounts;
return !jobCounts.active && !jobCounts.waiting;
},
waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => {
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);

View File

@@ -59,7 +59,7 @@ test.describe('Asset Viewer Navbar', () => {
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.keyboard.press('f');
await expect(page.locator('#notification-list').getByTestId('message')).toHaveText('Added to favorites');
await expect(page.getByText('Added to favorites')).toBeVisible();
});
});
});

View File

@@ -51,6 +51,6 @@ test.describe('Slideshow', () => {
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
await page.keyboard.press('f');
await expect(page.locator('#notification-list')).not.toBeVisible();
await expect(page.getByText('Added to favorites')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,51 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Maintenance', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/system-settings?isOpen=maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie
?.map((cookie) => cookie.split(';')[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy();
await expect(async () => {
await page.goto('/');
await page.waitForURL('**/maintenance?**', {
timeout: 1000,
});
}).toPass({ timeout: 10_000 });
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/auth/login');
});
});

View File

@@ -0,0 +1,864 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
getAsset,
getMockAsset,
SeededRandom,
selectRandom,
selectRandomMultiple,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import {
assetViewerUtils,
cancelAllPollers,
padYearMonth,
pageUtils,
poll,
thumbnailUtils,
timelineUtils,
} from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Timeline', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos', () => {
test('Open /photos', async ({ page }) => {
await page.goto(`/photos`);
await page.waitForSelector('#asset-grid');
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
});
test('Deep link to last photo', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, lastAsset.id);
});
const rng = new SeededRandom(529);
for (let i = 0; i < 10; i++) {
test('Deep link to random asset ' + i, async ({ page }) => {
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, asset.id);
});
}
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
const rng = new SeededRandom(22);
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await timelineUtils.locator(page).waitFor();
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(scrollTopAfter).toBe(scrollTopBefore);
});
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
const rng = new SeededRandom(49);
const asset = selectRandom(assets, rng);
const assetIndex = assets.indexOf(asset);
const nextAsset = assets[assetIndex + 1];
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
await page.goBack();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await page.waitForURL('**/photos?at=*');
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
});
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
await thumbnailUtils.clickAssetId(page, assets[0].id);
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets[15].id);
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
});
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.clickAssetId(page, lastAsset.id);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
});
});
test.describe('keyboard', () => {
/**
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
* as necessary downwards), then the asset should always be at the lowest row of the grid.
*/
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'ArrowRight';
const leftKey = 'ArrowLeft';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
}
});
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'Tab';
const leftKey = 'Shift+Tab';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
});
test('Next/previous day - d, Shift+D', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('d');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('d');
const next = getMockAsset(asset, assets, 'next', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+D');
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous month - m, Shift+M', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('m');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('m');
const next = getMockAsset(asset, assets, 'next', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+M');
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous year - y, Shift+Y', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('y');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('y');
const next = getMockAsset(asset, assets, 'next', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+Y');
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Navigate to time - g', async ({ page }) => {
const rng = new SeededRandom(4782);
await pageUtils.openPhotosPage(page);
for (let i = 0; i < 10; i++) {
const asset = selectRandom(assets, rng);
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
}
});
});
test.describe('selection', () => {
test('Select day, unselect day', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, click asset to select', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
// no need to hover, once selection is active
await thumbnailUtils.clickAssetId(page, assets[2].id);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
});
test('Select asset, click unselect asset', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
await thumbnailUtils.clickAssetId(page, assets[1].id);
// the hover uses a checked button too, so just move mouse away
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const asset = assets[0];
await thumbnailUtils.withAssetId(page, asset.id).hover();
await thumbnailUtils.selectButton(page, asset.id).click();
await page.keyboard.down('Shift');
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
await expect(
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
).toHaveCount(3);
await thumbnailUtils.selectButton(page, assets[2].id).click();
await page.keyboard.up('Shift');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
});
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
await thumbnailUtils.selectButton(page, assets[0].id).click();
await thumbnailUtils.clickAssetId(page, assets[2].id);
await page.keyboard.down('Shift');
await thumbnailUtils.clickAssetId(page, assets[4].id);
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
});
});
test.describe('scroll', () => {
test('Open /photos, random click scrubber 20x', async ({ page }) => {
test.slow();
await pageUtils.openPhotosPage(page);
const rng = new SeededRandom(6637);
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
for (const month of selectedMonths) {
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
const visibleMockAssetsYearMonths = await poll(page, async () => {
const assetIds = await thumbnailUtils.getAllInViewport(
page,
(assetId: string) => getYearMonth(assets, assetId) === month,
);
const visibleMockAssetsYearMonths: string[] = [];
for (const assetId of assetIds!) {
const yearMonth = getYearMonth(assets, assetId);
visibleMockAssetsYearMonths.push(yearMonth);
if (yearMonth === month) {
return [yearMonth];
}
}
});
if (page.isClosed()) {
return;
}
expect(visibleMockAssetsYearMonths).toContain(month);
}
});
test('Deep link to last photo, scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, -100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
});
test('Deep link to first bucket, scroll down', async ({ page }) => {
const lastAsset = assets.at(0)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, 100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
});
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
const lastMonth = yearMonths.at(-1);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
const sourcebox = (await lastScrubSegment.boundingBox())!;
const targetBox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets[0].id);
});
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const sourcebox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
const height = page.viewportSize()?.height;
expect(height).toBeDefined();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
steps: 100,
});
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
});
test('Buckets cancel on scroll', async ({ page }) => {
await pageUtils.openPhotosPage(page);
testContext.slowBucket = true;
const failedUris: string[] = [];
page.on('requestfailed', (request) => {
failedUris.push(request.url());
});
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
await offscreenSegment.click({ force: true });
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
await lastSegment.click({ force: true });
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
});
});
test.describe('/albums', () => {
test('Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
});
test('Deep link to last photo', async ({ page }) => {
const album = timelineRestData.album;
const lastAsset = album.assetIds.at(-1);
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
});
test('Add photos to album pre-selects existing', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.getByLabel('Add photos').click();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
});
test('Add photos to album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.locator('nav button[aria-label="Add photos"]').click();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
const requestJson = request.postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
changes.albumAdditions.push(...requestJson.ids);
});
await page.getByText('Done').click();
await expect(put).resolves.toEqual({
ids: [
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
'040fd762-dbbc-486d-a51a-2d84115e6229',
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
],
});
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
});
});
test.describe('/trash', () => {
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToTrash = assets[0];
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.getByText('Trash', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore', { exact: true }).click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
test('open album, trash photo, open /trash, restore', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore', { exact: true }).click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
});
test.describe('/archive', () => {
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToArchive = assets[0];
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
await route.fulfill({
status: 204,
});
changes.assetArchivals.push(...requestJson.ids);
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
const assetToFavorite = assets[0];
changes.assetArchivals.push(assetToFavorite.id);
await pageUtils.openArchivePage(page);
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
});
test('open album, archive photo, open album, unarchive', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
changes.assetArchivals.push(...requestJson.ids);
await route.fulfill({
status: 204,
});
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
});
test.describe('/favorite', () => {
test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToFavorite = assets[0];
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.getByRole('link').getByText('Favorites').click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
await pageUtils.openFavorites(page);
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
await route.fulfill({
status: 204,
});
changes.assetArchivals.push(...requestJson.ids);
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
});
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
});
});
const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
return dateTime.year + '-' + dateTime.month;
};

View File

@@ -0,0 +1,238 @@
import { BrowserContext, expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/generators/timeline';
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-');
return `${year}-${month.padStart(2, '0')}`;
};
export async function throttlePage(context: BrowserContext, page: Page) {
const session = await context.newCDPSession(page);
await session.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 40,
connectionType: 'cellular3g',
});
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>(
page: Page,
query: () => Promise<T>,
callback?: (result: Awaited<T> | undefined) => boolean,
) => {
let result;
const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try {
result = await query();
} catch {
// ignore
}
if (signal.aborted) {
return;
}
if (page.isClosed()) {
return;
}
try {
await page.waitForTimeout(50);
} catch {
return;
}
}
if (!result) {
// rerun to trigger error if any
result = await query();
}
return result;
};
export const thumbnailUtils = {
locator(page: Page) {
return page.locator('[data-thumbnail-focus-container]');
},
withAssetId(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
selectButton(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
},
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = [];
for (const thumb of await this.locator(page).all()) {
const box = await thumb.boundingBox();
if (box) {
const assetId = await thumb.evaluate((e) => e.dataset.asset);
if (collector?.(assetId!)) {
return [assetId!];
}
assetIds.push(assetId!);
}
}
return assetIds;
},
async getFirstInViewport(page: Page) {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
},
async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
},
async expectThumbnailIsFavorite(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
},
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
},
async expectThumbnailIsArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
},
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {
const first = await thumbnailUtils.getFirstInViewport(page);
if (page.isClosed()) {
return;
}
expect(first).toBeTruthy();
},
async expectInViewport(page: Page, assetId: string) {
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
if (page.isClosed()) {
return;
}
expect(box).toBeTruthy();
},
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
},
async expectTopIsTimelineTop(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
},
};
export const timelineUtils = {
locator(page: Page) {
return page.locator('#asset-grid');
},
async waitForTimelineLoad(page: Page) {
await expect(timelineUtils.locator(page)).toBeInViewport();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
},
async getScrollTop(page: Page) {
const queryTop = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.querySelector('#asset-grid').scrollTop;
});
await expect.poll(queryTop).toBeGreaterThan(0);
return await queryTop();
},
};
export const assetViewerUtils = {
locator(page: Page) {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.activeElement?.dataset?.asset;
});
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
},
};
export const pageUtils = {
async deepLinkPhotosPage(page: Page, assetId: string) {
await page.goto(`/photos?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openPhotosPage(page: Page) {
await page.goto(`/photos`);
await timelineUtils.waitForTimelineLoad(page);
},
async openFavorites(page: Page) {
await page.goto(`/favorites`);
await timelineUtils.waitForTimelineLoad(page);
},
async openAlbumPage(page: Page, albumId: string) {
await page.goto(`/albums/${albumId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openArchivePage(page: Page) {
await page.goto(`/archive`);
await timelineUtils.waitForTimelineLoad(page);
},
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
await page.goto(`/albums/${albumId}?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async goToAsset(page: Page, assetDate: string) {
await timelineUtils.locator(page).hover();
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
await page.keyboard.press('g');
await page.locator('#datetime').pressSequentially(stringDate);
await page.getByText('Confirm').click();
},
async selectDay(page: Page, day: string) {
await page.getByTitle(day).hover();
await page.locator('[data-group] .w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug');
await new Promise(() => void 0);
},
};

View File

@@ -52,14 +52,18 @@ test.describe('User Administration', () => {
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit user' }).click();
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(true);
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(true);
});
test('revoke admin access', async ({ context, page }) => {
@@ -77,13 +81,17 @@ test.describe('User Administration', () => {
await page.goto(`/admin/users/${user.userId}`);
await page.getByRole('button', { name: 'Edit user' }).click();
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(false);
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(false);
});
});

View File

@@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

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