Author SHA1 Message Date
Codex 132af2fc74 Prepare local14 web and Android release
Build Mobile / pre-job (push) Canceled after 0s
CLI Build / CLI Publish (push) Canceled after 0s
CodeQL / Analyze (javascript) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
Docker / pre-job (push) Canceled after 0s
Docs build / pre-job (push) Canceled after 0s
Migration Order / Verify migration ORDER (push) Canceled after 0s
Static Code Analysis / pre-job (push) Canceled after 0s
Test / pre-job (push) Canceled after 0s
Test / ShellCheck (push) Canceled after 0s
Test / OpenAPI Clients (push) Canceled after 0s
Test / SQL Schema Checks (push) Canceled after 0s
Build Mobile / Build and sign Android (push) Canceled after 0s
Build Mobile / Build and sign iOS (push) Canceled after 0s
CLI Build / Docker (push) Canceled after 0s
Docker / Re-Tag ML (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Re-Tag ML (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Re-Tag ML (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Re-Tag ML (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Re-Tag ML (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Re-Tag ML (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Re-Tag Server (push) Canceled after 0s
Docker / Build and Push ML (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Build and Push ML (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Build and Push ML (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Build and Push ML (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Build and Push ML (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Build and Push ML (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Build and Push Server (push) Canceled after 0s
Docker / Mirror to Docker Hub (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Mirror to Docker Hub (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Mirror to Docker Hub (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Mirror to Docker Hub (immich-server) (push) Canceled after 0s
Docker / Mirror to Docker Hub (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Mirror to Docker Hub (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Mirror to Docker Hub (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Docker Build & Push Server Success (push) Canceled after 0s
Docker / Docker Build & Push ML Success (push) Canceled after 0s
Docs build / Docs Build (push) Canceled after 0s
Static Code Analysis / Run Dart Code Analysis (push) Canceled after 0s
Test / Scripts unit tests (push) Canceled after 0s
Test / Test & Lint Server (push) Canceled after 0s
Test / Unit Test CLI (push) Canceled after 0s
Test / Unit Test CLI (Windows) (push) Canceled after 0s
Test / Lint Web (push) Canceled after 0s
Test / Test Web (push) Canceled after 0s
Test / Test i18n (push) Canceled after 0s
Test / End-to-End Lint (push) Canceled after 0s
Test / Medium Tests (Server) (push) Canceled after 0s
Test / End-to-End Tests (Server & CLI) (ubuntu-24.04-arm) (push) Canceled after 0s
Test / End-to-End Tests (Server & CLI) (ubuntu-latest) (push) Canceled after 0s
Test / End-to-End Tests (Web) (ubuntu-24.04-arm) (push) Canceled after 0s
Test / End-to-End Tests (Web) (ubuntu-latest) (push) Canceled after 0s
Test / End-to-End Tests Success (push) Canceled after 0s
Test / Unit Test Mobile (push) Canceled after 0s
Test / Unit Test ML (push) Canceled after 0s
Test / .github Files Formatting (push) Canceled after 0s
2026-10-29 20:44:43 +03:00
Zack PollardandCodex 9e5bdda89c chore(deps): update base-image to v202610291416 (#32810)
(cherry picked from commit a0c5b32fb8bfd3c3660e3a5a1ee038d3997bdd5b)
2026-10-29 20:43:01 +03:00
Zack PollardandCodex 79ea67f6be feat(server): compress database backups with pigz when it is installed (#32806)
(cherry picked from commit 97af09ca3cbd7f08ea033f176e777c9a9de8fdb5)
2026-10-29 20:43:01 +03:00
BaneinaandCodex 79b175465e fix(server): normalize bounding boxes when matching detected faces to existing faces (#32486)
(cherry picked from commit 5583cd7857a414f10039a9fb5bf03867bd4390c8)
2026-10-29 20:43:01 +03:00
Bautrell12andCodex c76bd08f73 feat(web): enable the shortcut "f" to favorite/unfavorite in multiselection mode (#31411)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit aeee9be333f4faac877b99cacd355329a5001c25)
2026-10-29 20:43:01 +03:00
Daniel DietzlerandCodex f2e05e9d98 chore: migrate oauth claim validation to zod (#32373)
(cherry picked from commit 0808f24a37cf376a509f1b4046cd52fd2d72b20d)
2026-10-29 20:43:01 +03:00
DawidKrynskiandCodex c8a957a116 fix(mobile): use context search once server features are loaded (#31800)
Co-authored-by: Dawid Krynski <188586034+DawidKrynski@users.noreply.github.com>
Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit be8e42526700d9ec5e72061e33f5039d50e40bae)
2026-10-29 20:43:01 +03:00
Ethan FerrierandCodex f7697264ed fix(mobile): ocr delete by asset (#32611)
(cherry picked from commit 56ed326acda2220fc7e3b21b17b3c8d24daf99cc)
2026-10-29 20:43:01 +03:00
Jason RasmussenandCodex 648dea2072 chore: remove isar reference (#32800)
(cherry picked from commit 323e750eb50ee0728110c8a1c5902c63cbda5850)
2026-10-29 20:42:12 +03:00
renovate[bot]andCodex 895b755f53 chore(deps): update base-image to v202610271109 (#32799)
(cherry picked from commit a56303f393ebb3a16f191a6f9a442d15285398cb)
2026-10-29 20:42:12 +03:00
renovate[bot]andCodex 568a9d977e chore(deps): update dependency @immich/ui to v0.91.1 (#32798)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit d5918c00420601f1ec97e6d67425c81958b3eb80)
2026-10-29 20:42:12 +03:00
Brent SaltzmanandCodex 3c383e46d1 fix: avoid duplicate manufacturer in camera name (#32657)
* fix(web,mobile): avoid duplicate manufacturer in camera name

Some cameras (e.g. Canon) already include the manufacturer in the EXIF
model, which made the details view show "Canon Canon EOS R6m2". Only
prepend the make when the model does not already start with it.

* refactor(web,mobile): address review feedback on camera name helper

- Use toLowerCase instead of toLocaleLowerCase for the comparison
- Hoist the whitespace regex to a static in the Dart helper
- Match any word of the make, so "EASTMAN KODAK COMPANY" with
  "KODAK EASYSHARE C875" is deduplicated too
- Compute the camera name once in DetailPanel

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit cb2ea1e308d8a85f2f340f50b646f161d3528670)
2026-10-29 20:41:02 +03:00
xuanandCodex 477fcc2338 docs(i18n): remove the deleted star-history chart from the remaining 16 translated READMEs (#32549)
(cherry picked from commit d31befb8514fe2f0ade360b6477abb90a940fd73)
2026-10-29 20:41:02 +03:00
renovate[bot]andCodex ff52f491fb chore(deps): update dependency onnxruntime to v1.31.0 (#32582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 257090aaaedf0d7dcff9e2295f55524f786dd9ae)
2026-10-29 20:41:02 +03:00
Valentin AgachiandCodex c3933169de feat(server): add OCR extraction workflow filter (#32464)
feat(server): add OCR workflow filter

(cherry picked from commit 2e0eb35e93dcf8468398d7015daa5ab231c7dd9c)
2026-10-29 20:41:01 +03:00
Codex 2c1f321627 Align local web integrations with updated lint rules and Cast types 2026-10-28 21:00:29 +03:00
Codex e8c1fd297d Prepare local13 release and align dependency lockfile 2026-10-28 20:46:38 +03:00
bo0tzzandCodex 8e3e3a48ae perf(server): only serialize exif for returned search rows (#32620)
(cherry picked from commit e51de38c40a29fd5eb1347cd991bbbafccfe62c6)
2026-10-28 20:41:41 +03:00
austinmdillowandCodex dc5553b239 feat: workflow filter by missing EXIF property (#32713)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit a1a80cb1c416d3a480963a78d7b9df329d867bf6)
2026-10-28 20:41:41 +03:00
renovate[bot]andCodex 32e02a05d2 chore(deps): update terraform cloudflare to v5 (#32794)
(cherry picked from commit d8aaa950099f6687a8f6861f63466242a748ac1b)
2026-10-28 20:41:41 +03:00
shenlongandCodex c581bce715 fix: include partner shared assets for people even without showInTimeline (#32675)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
(cherry picked from commit 21ed7454836073a646273c7475b347e043439a90)
2026-10-28 20:41:41 +03:00
Daniel DietzlerandCodex dd23264385 chore: deploy docs when terraform changes (#32795)
(cherry picked from commit 4ea9c1f7c520f1e1c6b6f22968abdb7f9b4aa29f)
2026-10-28 20:41:41 +03:00
Jason RasmussenandCodex 6dbe72b6a1 fix: gc time for long-lived queries (#32793)
(cherry picked from commit 079568691580605e92c5d3c3c9037cfbd657f374)
2026-10-28 20:41:41 +03:00
Daniel DietzlerandCodex 72f96701f0 feat: cluster group admin permission for regenerate people (#32787)
(cherry picked from commit 9f273a604d5bdbd9583758c3a79d2fc0141c7a77)
2026-10-28 20:41:41 +03:00
Jason RasmussenandCodex b4ae7f0dcf feat: passkey feature level (#32791)
(cherry picked from commit f5d33ff6618510917fb9f7bce705ec9583a207fb)
2026-10-28 20:41:41 +03:00
renovate[bot]andCodex 09bb2a9fb5 fix(deps): update typescript-projects (#32759)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit f26b9c83adb12848e94bcfc77e1b67be1756bdc9)
2026-10-28 20:41:41 +03:00
bo0tzzandCodex 8f2bfa099e fix(server): scope duplicate group resolution to the caller's assets (#32603)
(cherry picked from commit 7bb91afbe15e82502f8f6c92b61edb3865ce76f3)
2026-10-28 20:41:28 +03:00
bo0tzzandCodex 07083aec22 fix(server): anchor the folder view path match (#32612)
(cherry picked from commit c76e1517321df98df6e9b2f856493ac49ec05877)
2026-10-28 20:41:28 +03:00
bo0tzzandCodex 64d50dc4b7 perf(server): index asset (ownerId, updateId) for sync (#32624)
(cherry picked from commit 73603bcafe5d1e52434f736476d07c9578f3b888)
2026-10-28 20:41:28 +03:00
renovate[bot]andCodex 7fb7699c06 chore(deps): update dependency eslint-plugin-unicorn to v78 (#32588)
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 72e2bc8b0bc4a7e0a84ac16c0bde7f5e538729fb)
2026-10-28 20:41:28 +03:00
Santo ShakilandCodex 3127dbec71 refactor(mobile): move the native core decoders into a codecs crate (#32765)
* refactor(mobile): move the native core decoders into a codecs crate

* make Format private

* rename the decode export and options for what they hold

* drop the unused ios and server groups and rename the target params

(cherry picked from commit 11bf875313782b8a9e10269bb6f85226b7bdcd72)
2026-10-28 20:40:53 +03:00
Horatiu George TodoranandCodex f418967d55 perf: RKNN half RAM model usage + no weight duplication in multi-core (#23958)
* perf(ml): share RKNN weights across NPU cores and halve model RAM

* perf(ml): default rknn threads to npu cores and cache shape setup

* perf(ml): serve rknn runs in arrival order

(cherry picked from commit e659fb62f2bb3f6b1db08ed643d3d054a539c415)
2026-10-28 20:40:53 +03:00
YarosandCodex 1f422ea4c4 fix(server): add album-shared assets to albums (#32609)
Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit 37c34343e1b98edf07dc3ff34c5da461de03c746)
2026-10-28 20:40:53 +03:00
Codex 8119a5ee22 Finalize local12 formatting and document guarded browser APIs 2026-10-27 23:27:18 +03:00
Codex 54881128d6 Adapt local features and dependency locks for SvelteKit 3 and local12 2026-10-27 23:17:33 +03:00
renovate[bot]andCodex d15be0dd83 chore(deps): update rust crate libc to v0.2.191 (#32753)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 12381a3b4bca8634f785c23cd4c6bf4063477b3b)
2026-10-27 23:12:52 +03:00
Eduard Ereza MartínezandCodex 47cc38061e fix(docs): update README_ca_ES.md to match the English version (#30300)
* Update README_ca_ES.md to match the English version

* fix(docs): update README_ca_ES.md to match the English version

(cherry picked from commit 0fe886d1b573d579fdb3fd243a477e0e6ac08f52)
2026-10-27 23:12:52 +03:00
MertandCodex db411a4497 feat(server): add jxl as target format (#32732)
(cherry picked from commit 9560989cb97fb93cd6ab74f7811acce133a9b768)
2026-10-27 23:12:52 +03:00
renovate[bot]andCodex f1a09c1389 chore(deps): update dependency fastapi to v0.160.0 (#32756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit cadb945108d7a547845ba5279c745fca64ba2cf8)
2026-10-27 23:12:52 +03:00
renovate[bot]andCodex c8222bba94 chore(deps): update dependency terragrunt to v1.2.0 (#32757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit e50ada17c817499a131eefb58a112850dfd22ea6)
2026-10-27 23:12:51 +03:00
TimonandCodex af66681b99 chore: bump openapi-generator version to v7.26 (#32454)
(cherry picked from commit b3c1b3d260b0186a9d6e82277f0e57a6e6d88738)
2026-10-27 23:12:42 +03:00
bo0tzzandCodex a93bbdf3e7 feat: inline hashes in helmet CSP (#30529)
* feat: inline hashes in helmet CSP

* tests

* fix: svelte3 merge

* chore: clean up

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit 02a5d4b2060dad6f5779c196b7bb2b43f871ffb2)
2026-10-27 23:12:41 +03:00
bo0tzzandCodex 1707635743 fix(server): require update access on the motion video when linking a live photo (#32606)
* fix(server): require update access on the motion video when linking a live photo

* chore: allow linking with existing shared link assets

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit a7afb4c9e9d2f099f9204a41f62e17b54fca3b17)
2026-10-27 23:12:41 +03:00
renovate[bot]andCodex 7a6123f8e6 fix(deps): update dependency @immich/ui to ^0.91.0 (#32587)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit d073539afdd20530baaf85ba414f396b94112b5c)
2026-10-27 23:12:40 +03:00
renovate[bot]andCodex 4960eddaf3 fix(deps): update typescript-projects (#32755)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 8aa9054366fac2738799a2356f9b760a2a441709)
2026-10-27 23:12:40 +03:00
bo0tzzandCodex d35a6613e6 chore: tighten auto-close template check (#32719)
(cherry picked from commit 2400de96668d3510a1a50dd1c658d483e9ad01aa)
2026-10-27 23:12:21 +03:00
Mees FrenselandCodex f96816b55f refactor(web): bulk (un)archive actions (#32189)
(cherry picked from commit d26c0eec10f1bb49117b71b667c88a8a715b2489)
2026-10-27 23:12:21 +03:00
YarosandCodex eca952720a fix: permission for admin notification endpoints (#32567)
(cherry picked from commit 045d295d2d0b506af6f2abe14d1ed2cb89c5235f)
2026-10-27 23:12:13 +03:00
Kairui LiuandCodex c482c1e53e fix: preserve stack primary asset selection (#31380)
* fix: preserve stack primary asset selection

* test: select stacked assets by id

* chore: limit dart formatting changes

* fix: address stack viewer review feedback

* fix: align stack ordering with server

* fix: keep stack primary asset first

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
(cherry picked from commit 5a5d7be11084969c3c00a294ce6c0527042f88e7)
2026-10-27 23:12:13 +03:00
cevdetandCodex 75a1d7c5c8 perf(web): reduce favicon assets (#32594)
(cherry picked from commit 5177913e0a1dba27d5d46ae4ba46222e94e17d0b)
2026-10-27 23:12:13 +03:00
Mees FrenselandCodex 5630e541e3 refactor(web): clean up (#32627)
(cherry picked from commit 5ff8436781681755042fe6416024d0c4885b851d)
2026-10-27 23:12:13 +03:00
Mees FrenselandCodex 27ccbee82d refactor(web): use svelte builtin id generator (#32631)
(cherry picked from commit 285552785f1bd7cb5ad431c8b3423d1d5e858188)
2026-10-27 23:12:13 +03:00
Antonio SarroandCodex 3458767d66 fix(web): downloads aborted when switching assets in the viewer (#32663)
* fix(web): downloads aborted when switching assets in the viewer

The service worker also handled the download request, so the viewer cancelling its image urls killed the download too. Only handle image requests there.

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit 8c3fdda7ead1a70158780e45a67a11413b208dba)
2026-10-27 23:12:13 +03:00
Mees FrenselandCodex cd28300f94 fix(web): show metadata field if it is missing for one duplicate asset (#32601)
(cherry picked from commit b203227fdddff02c4686ff8db9d6054818900929)
2026-10-27 23:12:13 +03:00
Maximilian RinkandCodex d8030f3d14 fix(server): index edited assets in smart search (#32300)
(cherry picked from commit adca900fe277bd6dff93b96b6a7841a8632ce62b)
2026-10-27 23:12:13 +03:00
renovate[bot]andCodex 0ff6470791 chore(deps): update dependency opentofu to v1.13.2 (#32751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 7cae55ec4ee7ab53d610cdf82b04a817e4ad8e8e)
2026-10-27 23:12:13 +03:00
renovate[bot]andCodex 717152b191 chore(deps): update terraform cloudflare to v4.52.10 (#32754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 054e6cdce4b0f94c15fa3f4c0d0a076742f4a4d0)
2026-10-27 23:11:57 +03:00
renovate[bot]andCodex 06dbcf7c84 chore(deps): update prom/prometheus docker digest to fa33022 (#32749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 1a1f15639011971d1674b124d6f0bbdaaf053822)
2026-10-27 23:11:57 +03:00
renovate[bot]andCodex 9767ea9c02 chore(deps): update node.js to 43b9ba1 (#32748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 0632acf701f3d9f559199bee89b5d3cd86b84822)
2026-10-27 23:11:45 +03:00
JakksonnandCodex 071b8db397 docs: add Polish README translation (#32745)
* docs: added Polish README translation

* docs: improved translations

(cherry picked from commit 5ce14cff4db45fc7ed544f6ec52eef4bc35ef6e2)
2026-10-27 23:11:45 +03:00
renovate[bot]andCodex 1c9813e130 chore(deps): update github-actions (#32758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 8875673b6856eb1b793137cd7b0e2be76acc6f0b)
2026-10-27 23:11:45 +03:00
Santo ShakilandCodex 6569aad605 feat(server): accept the iOS app origin for mobile passkeys (#32733)
(cherry picked from commit 1119e9c7a7c9f5ad657aa4e893b897772f75a124)
2026-10-27 23:11:30 +03:00
renovate[bot]andCodex a2794f0986 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.10.20 (#32752)
(cherry picked from commit 506c83e8265bba829505deb23ad05a62eca579fc)
2026-10-27 23:11:30 +03:00
renovate[bot]andCodex 6ffc365b75 fix(deps): update dependency chokidar to v6 (#32760)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 4c33587fe88c52e50cb63dc8cea347fac6ef01ac)
2026-10-27 23:11:30 +03:00
renovate[bot]andCodex dc19d3a259 fix(deps): update opentelemetry-js monorepo to v3 (#32762)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 14977d6cc8c27e226334681ba878aa1243887055)
2026-10-27 23:11:29 +03:00
renovate[bot]andCodex f2a502448d chore(deps): update dependency @sveltejs/enhanced-img to v1 (#32395)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit df50249c03ddac9417a2172cfbbaef848163ed4f)
2026-10-27 23:11:28 +03:00
Jason RasmussenandCodex a3ffe3b3c3 refactor: asset face queries (#32679)
(cherry picked from commit 5077a85d5e6775c48b38de7fcf25f2a3864b6a83)
2026-10-27 23:11:20 +03:00
Daniel DietzlerandCodex 8125af4859 chore: migrate web imports from .js to .ts (#32742)
(cherry picked from commit f97c2243fbe5f64a4e1bf96128b82a37cc481506)
2026-10-27 23:11:20 +03:00
Daniel DietzlerandCodex e5b4dd1041 feat: sveltekit 3 (#32737)
(cherry picked from commit 2576ee28cfb790e99ddf401a129e1d9a18713121)
2026-10-27 23:11:06 +03:00
Codex 621504fcda Prepare local11 release and cover concurrent JPEG XL color transforms 2026-10-26 22:38:18 +03:00
YarosandCodex 919a4fd9ef fix: watch asset delete/archive in search (#31343)
* fix: watch asset delete/archive in search

* fix: restore via toast

* chore: format

* refactor: simplify search asset pruning

* chore: implement suggestions

* chore: more suggestions

* fix: mounted check

(cherry picked from commit f7b8ae5da370f947223b006618e5b4e5fb42fd33)
2026-10-26 22:35:01 +03:00
MertandCodex 2ed9207c3f perf(mobile): cache srgb transform (#32730)
(cherry picked from commit 18f1a44d1b934fc9d98ddb2be6918b7617c9c896)
2026-10-26 22:34:24 +03:00
Mees FrenselandCodex c48394aa6e refactor(web): use declaration tags instead of @const (#32639)
(cherry picked from commit c6d1e15c77c5f3786b26b292def27247b258cde5)
2026-10-26 22:34:24 +03:00
renovate[bot]andCodex 86c8b9e04d chore(deps): update machine-learning (#32125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 256eb55dbe92ac77e02794442f9487e88ffed2b1)
2026-10-25 16:24:17 +03:00
renovate[bot]andCodex 1190d0cc80 fix(deps): update dependency pillow to v13 [security] (#32621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit ea7080bd755f7e879e0753dea59fabde331667b7)
2026-10-25 16:23:45 +03:00
bo0tzzandCodex 9669f7cc6e perf(server): use a createdAt range for "recently added" time buckets (#32434)
(cherry picked from commit cf719e733e6250e0fcd8f45d2e2c46f166d3557d)
2026-10-25 16:23:45 +03:00
Codex a5b2dcb3c2 Define the JPEG XL decode bound for the local Android image pipeline 2026-10-24 12:21:17 +03:00
Codex f94fbfe14e Integrate native core build prerequisites for local Android release 2026-10-24 11:58:44 +03:00
MertandCodex 70ef2884f0 feat(mobile): jxl decoder (#32592) 2026-10-24 11:52:05 +03:00
Santo ShakilandCodex 918d66fbdb fix(mobile): cancel cronet image requests only between reads (#32659)
* fix(mobile): cancel cronet image requests only between reads

* rename cronet callback flags
2026-10-24 11:51:43 +03:00
Jason RasmussenCodexcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>jrasm91
5d1167a36c refactor: apikeys, passkeys, and sessions (#32670)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jrasm91 <4334196+jrasm91@users.noreply.github.com>
2026-10-24 11:51:43 +03:00
Jason RasmussenandCodex e3bf54e2ba refactor: album queries (#32668) 2026-10-24 11:51:43 +03:00
Codex d17640a283 Handle optional OAuth config in visibility regression assertion 2026-10-22 21:15:41 +03:00
Codex 9e889bd4dc Adapt local regression tests to new configuration queries 2026-10-22 20:48:48 +03:00
Codex ca4d8d5a7a Prepare 3.3.5-local.8 and complete deletion regression test imports 2026-10-22 20:44:45 +03:00
Jason RasmussenandCodex 5f54700344 refactor: folder view queries (#32667)
(cherry picked from commit 6bf016964515ed65bb45cd1cf7b7bfadb405559a)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex 0991f506bb refactor: shared link queries (#32666)
(cherry picked from commit 0a73481a8fdb3cfbab29f23da48e26133dc4c064)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex fa77f168a0 refactor: notification queries (#32653)
(cherry picked from commit 8f3ba2c808e02e2a606b6acc65ce22f8ee3f9b34)
2026-10-22 20:43:53 +03:00
gitTurbofliegerandCodex 3aa65809b1 fix(mobile): stop trashing assets after deletion cancellation (#32522)
* fix(mobile): stop trashing assets after deletion cancellation

* fix(mobile): handle Android system delete cancellation

* fix(mobile): simplify

* simplify test

(cherry picked from commit 3517973b8a316c8fb729bf82fe4bbf95884b9146)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex 9a66f16102 fix: account management url visibility (#32645)
(cherry picked from commit 22fe7b4179464c73d7e0487df90f2064fa30a5e0)
2026-10-22 20:43:14 +03:00
Jason RasmussenandCodex 493870ad7b refactor: config queries (#32498)
(cherry picked from commit b45ec0f55abb9dc39fbbb39dcf5296d45a726ba0)
2026-10-22 20:43:14 +03:00
Jason RasmussenandCodex 1e20bdd74e refactor: workflow queries (#32654)
(cherry picked from commit 98680ad6993ed917e630f8f4d1dbd1fbb6c41936)
2026-10-22 20:43:14 +03:00
Santo ShakilandCodex 99a7830f6c fix(mobile): bump sqlite3_connection_pool to 0.2.13 (#32662)
* fix(mobile): bump sqlite3_connection_pool to 0.2.13

* fix(mobile): require sqlite3_connection_pool 0.2.13

(cherry picked from commit 539499ee5e3843d338287edcf6808c1820c74800)
2026-10-22 20:43:14 +03:00
Codex e04e70db2e Close startup log connection and use portable export filenames 2026-10-22 05:39:19 +03:00
Codex 75517057d0 Keep Svelte compiler warning option portable with pnpm 12 2026-10-22 05:33:35 +03:00
Codex 3efc91ff9a Prepare localized 3.3.5-local.7 release with updated dependencies 2026-10-22 05:29:35 +03:00
Clément DelestreandCodex 7c07c001cd fix(mobile): keep header image when starting selection (#32356)
* fix(mobile): keep header image when starting selection

* fix(mobile): fade out header actions during selection

Hide the back button and person options menu with the same fade as the
timeline app bar, and block taps on the person name and birthday while
assets are selected.

---------

Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit 745bdd2edf72ce0f75e748e2d68c37b6ea196cc5)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex f1f7f82e91 chore(deps): update base-image to v202610211607 (#32644)
Co-authored-by: bo0tzz <git@bo0tzz.me>
(cherry picked from commit b9269f873982c9b1c0f9267aefad8b8b9eacb7d9)
2026-10-22 05:25:45 +03:00
bo0tzzandCodex b3bd5d1fd1 feat(server): remove deprecated password field from update-me (#32623)
* feat(server): remove deprecated password field from update-me

* fix: tests for removed password field

(cherry picked from commit 3f6a071203187681b6c06b977665dc624c431336)
2026-10-22 05:25:45 +03:00
bo0tzzandCodex ad9b368cb8 fix(server): exclude partners' locked assets from legacy search (#32604)
(cherry picked from commit 8dab52c7491c79cba742315dea8d7f5212915875)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 7c78b00df6 fix(deps): update dependency ioredis to v6 (#32137)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 48474ecff56fe02cb84a8136d923863d4978a7e0)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 4dfb6e88af chore(deps): update node.js to v24.22.1 (#32633)
Co-authored-by: bo0tzz <git@bo0tzz.me>
(cherry picked from commit c5086b68f76b78964e22c52bd78060344d82aebf)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 744797e59d chore(deps): update node.js to v24.22.1 (#32634)
(cherry picked from commit c78e5c37843916199e380f077add45bd15a8bba3)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 689105c949 fix(deps): update dependency exiftool-vendored to v39 (#31917)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit bb9f18a1d007d530fcf477a0fa56528fdfa91dd0)
2026-10-22 05:25:45 +03:00
bo0tzzandCodex 1332a30630 chore(deps): update dependency certifi to v2026.10.19 (#32636)
(cherry picked from commit cda02c46e6cb36cf17d54570b2a024c51111cdf0)
2026-10-22 05:25:15 +03:00
bo0tzzandCodex 648e5608b7 chore(deps): update python docker digests (#32635)
(cherry picked from commit f97b00284d5752c2cddced549527ae692e820957)
2026-10-22 05:25:14 +03:00
Jonathan JogenforsandCodex 42dffaa0c8 fix(server): dont follow symlinks (#32614)
(cherry picked from commit 10e216b8385ac40e6aa150f75fc4730c4e8a7494)
2026-10-22 05:24:55 +03:00
renovate[bot]andCodex ec3359f1cb fix(deps): update typescript-projects (#32132)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 767f47a8fb35e0605e49b0dd4f30587bb76085ad)
2026-10-22 05:23:59 +03:00
renovate[bot]andCodex 235e57bf77 fix(deps): update dependency cookie to v2 (#31916)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit e505e5113b43177ad215d075dca25dc056dd1a40)
2026-10-22 05:23:59 +03:00
Adam GastineauandCodex 630240971f chore(mobile): use Xcode and iOS 27.1 SDK for CI builds (#32632)
(cherry picked from commit cb4195ad439ba962a2def7f04738eafc749554d9)
2026-10-22 05:23:59 +03:00
renovate[bot]andCodex 44e312cfba chore(deps): update pnpm to v12 (#31911)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 638cfbca5e06486caa00423a83d557c66d6a5ae3)
2026-10-22 05:23:59 +03:00
renovate[bot]andCodex 67c158a0ab chore(deps): update grafana/grafana docker tag to v13 (#31910)
(cherry picked from commit 1d4be15bc59d25b180ee8712e784d73d8e9d6889)
2026-10-22 05:23:24 +03:00
renovate[bot]andCodex 1554f0d439 chore(deps): update dependency unplugin-swc to v2 (#31909)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 493b40a7a6c34dac05533c3d3682a38f0eef6038)
2026-10-22 05:23:24 +03:00
renovate[bot]andCodex 3e250d7878 fix(deps): update dependency nodemailer to v10 [security] (#31907)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 5d2cd0800efad0e396790782af25378c8cb345ab)
2026-10-22 05:23:24 +03:00
renovate[bot]andCodex a6d63285b7 fix(deps): update dependency chokidar to v5 (#31915)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit d1e874fc1e16ab53c80f723dd1905cd7e106cac0)
2026-10-22 05:23:23 +03:00
renovate[bot]andCodex c1552140b0 fix(deps): update dependency happy-dom to v21 (#32589)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit f1cd87e37be8a47f463cb53241a651407a0eebf2)
2026-10-22 05:23:23 +03:00
renovate[bot]andCodex 238617e566 fix(deps): update dependency archiver to v8 (#31913)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 51da695c62f3415a97c7b752ef62927e39ab086f)
2026-10-22 05:23:23 +03:00
YarosandCodex 3f3671fbe5 fix(ml): preloaded model loaded twice (#32610)
fix(ml): reuse cached model across names that clean to the same model

(cherry picked from commit 166e93559f123313cf9cb4e7324c4ee30e21c618)
2026-10-22 05:23:22 +03:00
bo0tzzandCodex 01ba579766 perf(server): index updateId on asset_face, stack and workflow (#32432)
asset_face, stack and workflow were the only tables with an updateId
column but no index on it. Face and stack sync (AssetFacesV1/V2,
StacksV1, PartnerStacksV1) filter and order on updateId, so every mobile
sync seq-scanned every face on the instance and walked all of the user's
(or partners') stacks, even when nothing had changed. workflow isn't
synced; it gets the index for consistency.

Incremental sync with nothing changed, PG 14:

| query                              | before   | after    |
| ---------------------------------- | -------- | -------- |
| assetFace.getUpserts (425k faces)  | 33-37 ms | 0.15 ms  |
| stack.getUpserts (6k stacks)       | 0.8 ms   | 0.004 ms |
| partnerStack.getUpserts (6k)       | 0.6 ms   | 0.007 ms |

The face scan grows with the instance's face count (~10x that at 4M
faces); the stack one with the user's stack count.

(cherry picked from commit 9df807ed4a9536a28dad463270082095ec1fe94d)
2026-10-22 05:23:22 +03:00
bo0tzzandCodex e2864b6664 fix(server): do not expose album members to shared link viewers (#32607)
(cherry picked from commit 380f3e74e622938b6b6c450859df8655be4e21ba)
2026-10-22 05:23:22 +03:00
bo0tzzandCodex 1756d70fda fix(server): only set OAuth cookies for server-generated state (#32600)
(cherry picked from commit d468c468867edcbafd4d934d3ba97bee2414050f)
2026-10-22 05:23:22 +03:00
Jason RasmussenandCodex b12be9119f feat: passkeys (#32194)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 6233a79bf7e4cec11e9ea1094908ff3b228fcbe0)
2026-10-22 05:23:22 +03:00
Santo ShakilandCodex e66d5a70e7 fix(mobile): hide server addresses in upload error logs (#32550)
* fix(mobile): hide server addresses in upload error logs

* fix(mobile): remove duplicate upload failure logs

(cherry picked from commit 211773f778ca24ac33e64d46e50b3ab46702c851)
2026-10-22 05:22:44 +03:00
renovate[bot]andCodex 1a81902a36 chore(deps): update dependency opentofu to v1.13.1 (#32130)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 99fb4b1fec4ec3e445a86307766c02552fdae982)
2026-10-22 05:22:12 +03:00
renovate[bot]andCodex ec5b4eb939 chore(deps): update grafana/grafana docker tag to v12.4.13-ubuntu (#32127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit f4720273e1c42f53bb7b218a3fc116ca2e1547a1)
2026-10-22 05:21:33 +03:00
renovate[bot]andCodex 3457823b5b chore(deps): update node.js to v24.22.0 (#32585)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 215c496f6276e5f0ffb8e8b9b6555e7684b954b1)
2026-10-22 05:21:33 +03:00
renovate[bot]andCodex ffbe3d1681 chore(deps): update node.js to v24.22.0 (#32586)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit cd5d091f72838fc4b16ce2c9ebb59686c415ecdf)
2026-10-22 05:21:33 +03:00
bo0tzzandCodex 5c0f0b498a fix(ci): remove scope labels when they no longer match (#31026)
(cherry picked from commit 5d7556fe6d0699fc932b94a328ecb5e5145a0fce)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex c46f748412 chore: link CONTRIBUTING.md in issue and feature request templates (#32514)
(cherry picked from commit f1984aa0905626ee05b5fd9fdef3cf35b77d951e)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex 59be6583b8 fix(server): only serve edited assets to shared links in archive downloads (#32562)
(cherry picked from commit cd47ee30676e8575a5048d12cde2ee86fc02b965)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex aeefb962d4 fix(server): sanitize EXIF template variables and tighten storage path check (#32566)
(cherry picked from commit 6141f8bdbf6d21a14558d9f7d7325918023d1037)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex f80be1030b fix(server): restrict partner timeline requests to timeline assets (#32565)
* fix(server): restrict partner timeline requests to timeline assets

* test(server): update medium timeline tests for consolidated check

(cherry picked from commit e9fb180f466a687cebda9b7e17cef403de4b81fa)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex 7df744b684 fix(cli): only act on files that were sent in the bulk upload check (#32564)
(cherry picked from commit 9e20805e21c72b95f8c23f4985c8a20b4c3da12a)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex c66634fcfd fix(server): escape template variables in custom email templates (#32563)
* fix(server): escape template variables in custom email templates

* fix(server): strip preview tags until stable

(cherry picked from commit 616839e8d43a9de21d0c9391847fba97ac41a759)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex d92248c664 fix(server): require upload access for checksum duplicate check (#32559)
* fix(server): require upload access for checksum duplicate check

* fix(server): use the upload access check for the checksum short circuit

(cherry picked from commit 5c02b17495947d7332cce13273226082bd8ae2a1)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex 67152772d2 fix(server): anchor plugin allowedHosts matching (#32560)
(cherry picked from commit 4acf935ba3ee7b6171e3965cb3ce47b2ba9bad1a)
2026-10-22 05:21:00 +03:00
Adam GastineauandCodex 564b720bce fix(mobile): prevent crash when generated/openapi has unexpected files (#32599)
(cherry picked from commit 4f38929bdaaa88d48429ac642abf9ec292e3ca82)
2026-10-22 05:21:00 +03:00
Tiago DeaneandCodex 6aaebd167d feat: option for mobile smooth timeline scrubbing (#32552)
* feat(mobile): add smooth timeline scrubbing setting

* refactor: read smooth scrubbing setting where the scrubber is built

(cherry picked from commit 222d9276d31e890d3d9beb6dae5d96a6f6ab52f0)
2026-10-22 05:21:00 +03:00
renovate[bot]andCodex 2d236024bc fix(deps): update dependency kysely-postgres-js to v5 (#31919)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 21fe4994a203da3876271bc290c47b69694fd42a)
2026-10-22 05:20:59 +03:00
renovate[bot]andCodex e09b352067 fix(deps): update typescript-projects (#32581)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 50edbedf15526c3b43c43a261a1aaddfb782b5ce)
2026-10-22 05:20:59 +03:00
MertandCodex 4c89b0f7df perf(server): more efficient person queries (#32523)
(cherry picked from commit 38a2ce4daa678615561d2b87a8f2bb30f70fdc53)
2026-10-22 05:20:19 +03:00
renovate[bot]andCodex b020f12f41 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.10.15 (#32580)
(cherry picked from commit a22480e3eb2c07535c126f7ac20f6e04f51c6200)
2026-10-22 05:19:50 +03:00
bo0tzzandCodex 43848a7d68 fix: locking around concurrency-1 jobs (#28410)
* fix: locking around concurrency-1 jobs

* fix: no lock on queue duplicates

(cherry picked from commit 477452b5c51ca71ecdf74205e0004eaec4e18874)
2026-10-22 05:19:10 +03:00
renovate[bot]andCodex 9e96fdc627 chore(deps): update github-actions (#32584)
(cherry picked from commit be825a0518addd098166471706a64c8305cafca2)
2026-10-22 05:19:10 +03:00
Mateus NavarroandCodex 434b7efd66 fix(mobile): border radius on list tiles in library page (#32476) (#32503)
* fix(mobile): standardize list tile radius in Quick Access Button List when pressed (#32476)

* fix(mobile): adjust list tile border radius based on partner presence in Quick Access Button List

* refactor(mobile): improve way to handle border radius of items in the QuickAccessButtonList

(cherry picked from commit fff2bd73f9b38a5a5b510ea69a77a470dd1acf56)
2026-10-22 05:18:40 +03:00
Santo ShakilandCodex 814cc14f88 feat(mobile): share logs from the startup error screen (#32288)
* fix(mobile): hide server addresses and tokens in shared logs

* feat(mobile): share logs from the startup error screen

* use only the shared logs patterns on the startup error screen

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* redact the startup error screen share

* fix(mobile): hide server addresses and tokens in shared logs

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* fix(mobile): redact logs when they are written

* log the startup error and share it as a log row

* fix(mobile): hide ipv6 addresses and name labels after their settings

* comment why the startup error row is only added here

(cherry picked from commit f7fa8a3a2d2c3b0ab59f90fbcc3c230c58a2aede)
2026-10-22 05:18:40 +03:00
Santo ShakilandCodex 59bf69f1d5 fix(mobile): hide server addresses and tokens in shared logs (#32287)
* fix(mobile): hide server addresses and tokens in shared logs

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* fix(mobile): redact logs when they are written

* fix(mobile): hide ipv6 addresses and name labels after their settings

(cherry picked from commit 89d2de1274a3e1a70a6169f4cfe1feebfb327791)
2026-10-22 05:18:40 +03:00
1099 changed files with 20509 additions and 9777 deletions
@@ -9,6 +9,8 @@ body:
Stick to only a single feature per request. If you list multiple different features at once,
your request will be closed.
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening a feature request.
- type: checkboxes
attributes:
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
+5
View File
@@ -1,6 +1,11 @@
name: Report an issue with Immich
description: Report an issue with Immich
body:
- type: markdown
attributes:
value: |
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening an issue.
- type: checkboxes
attributes:
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
+9 -2
View File
@@ -26,10 +26,17 @@ jobs:
env:
BODY: ${{ github.event.pull_request.body }}
run: |
BODY=$(printf '%s\n' "$BODY" | tr -d '\r' | perl -0pe 's/<!--.*?-->//gs')
section() { printf '%s\n' "$BODY" | awk -v h="$1" 'index($0, h) == 1 { f = 1; next } /^## / { f = 0 } f'; }
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
done < <(perl -0pe 's/<!--.*?-->//gs' .github/pull_request_template.md | grep "^## ")
section "## Checklist" | grep -qE '^\s*- \[[xX]\]' || OK=false
[[ "$(section "## Please describe to which degree" | tr -d '[:space:]')" =~ ^(\.\.\.)?$ ]] && OK=false
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
close_template:
@@ -51,7 +58,7 @@ jobs:
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). After you edit it to match the template, the PR will automatically be reopened." \
-f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). Please make sure every section is present and filled in. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
+4 -4
View File
@@ -204,7 +204,7 @@ jobs:
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-26
runs-on: xcode-27
steps:
- id: token
@@ -214,8 +214,8 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- name: Select Xcode 26
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Select Xcode 27.1
run: sudo xcode-select -s /Applications/Xcode_27.1.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -230,7 +230,7 @@ jobs:
working_directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
uses: ruby/setup-ruby@e1bd6146574da5e0795075b5319b300edb30c5b0 # v1.333.0
with:
ruby-version: '3.4'
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@5e81b5c380accc6b523f9d32a637ca630e33620b # v0.1.17
uses: oasdiff/oasdiff-action/breaking@9ce3a29eb0e6478dd0140932a678c7fb59d08f22 # v0.1.20
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+5 -5
View File
@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,10 +71,10 @@ 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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
# ℹ️ 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
# в„№пёЏ Command-line programs to run using the OS shell.
# рџ“љ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: '/language:${{matrix.language}}'
+1
View File
@@ -35,6 +35,7 @@ jobs:
filters: |
docs:
- 'docs/**'
- 'deployment/**'
open-api:
- 'open-api/immich-openapi-specs.json'
force-filters: |
+1 -1
View File
@@ -115,7 +115,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1
View File
@@ -22,3 +22,4 @@ jobs:
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
sync-labels: true
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.1
+1
View File
@@ -33,6 +33,7 @@
<a href="readme_i18n/README_uk_UA.md">Українська</a>
<a href="readme_i18n/README_ru_RU.md">Русский</a>
<a href="readme_i18n/README_bg_BG.md">Български</a>
<a href="readme_i18n/README_pl_PL.md">Polski</a>
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
<a href="readme_i18n/README_ar_JO.md">العربية</a>
+42 -52
View File
@@ -1,79 +1,69 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.opentofu]]
version = "1.12.6"
version = "1.13.2"
backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
checksum = "sha256:88e2d9e3f94f97b76ab2faf55ade87578f56aed19c5e45fa139d70e994366d1f"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742799"
[tools.opentofu."platforms.linux-x64"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
checksum = "sha256:ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742636"
[tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
checksum = "sha256:0d1cdc85f38992055b48e7fb161304a2ddfe2e5ee0ff68ffec9d0349c8bd1b59"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742637"
[tools.opentofu."platforms.macos-x64"]
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
checksum = "sha256:dc6e4eb1d3ed6550c2ef052596f933dbcd8c806957665ea83ae7537147cb3074"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742982"
[tools.opentofu."platforms.windows-x64"]
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
checksum = "sha256:e74fc2bda0c81ce696b6b7016a4d5c1f557638161239e4101e9ee7d8e0faba59"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
[[tools.terragrunt]]
version = "1.0.3"
version = "1.2.0"
backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
checksum = "sha256:1e13b7f0646fa7962bc6c5bfba14a30d6a589325b82561e21b66be5981d86395"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437526"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
checksum = "sha256:23b5f2f9203d34919f7fd270a4b635b721b4903beca9762a1e4853b159bc9be8"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437263"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
checksum = "sha256:79790d841ce71fe94c689fb6f5034afd31ad53d5904937c7a10934cca9d9734b"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436516"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
checksum = "sha256:0b0e63e80509eb1ffb4f89b892bc6fbf7728f27f0a73974400f1bd82a80b8627"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436142"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
checksum = "sha256:c9a146b9aad456a289614023ad5778123d819af3cf9efd1c6130f196cfcb1999"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662438255"
+2 -2
View File
@@ -1,6 +1,6 @@
[tools]
terragrunt = "1.1.1"
opentofu = "1.12.6"
terragrunt = "1.2.0"
opentofu = "1.13.2"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"
+18 -28
View File
@@ -2,34 +2,24 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.9"
constraints = "4.52.9"
version = "5.28.1"
constraints = "5.28.1"
hashes = [
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
]
}
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.9"
version = "5.28.1"
}
}
}
+18 -28
View File
@@ -2,34 +2,24 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.9"
constraints = "4.52.9"
version = "5.28.1"
constraints = "5.28.1"
hashes = [
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.9"
version = "5.28.1"
}
}
}
+2 -2
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0
image: prom/prometheus@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.4.11-ubuntu@sha256:23f2328b859c7677c7065c81b479600706ef9cc9004977af5bbd68b159b8ea60
image: grafana/grafana:13.2.4-ubuntu@sha256:d838696907b4350d9232eb8da1503dc1d1010a0359d3ce4b3f7d2ff7db28eb75
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -34,7 +34,7 @@ The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://f
<MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" />
The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture.
Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
Currently, it uses [Drift](https://drift.simonbinder.eu/) (SQLite) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory.
The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes!
@@ -85,7 +85,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele
- RKNPU driver V0.9.8 or later must be available in the host server
- You may confirm this by running `cat /sys/kernel/debug/rknpu/version` to check the version
- Optional: Configure your `.env` file, see [environment variables](/install/environment-variables) for RKNN specific settings
- In particular, setting `MACHINE_LEARNING_RKNN_THREADS` to 2 or 3 can _dramatically_ improve performance for RK3576 and RK3588 compared to the default of 1, at the expense of multiplying the amount of RAM each model uses by that amount.
- By default each model gets one context per NPU core (1 on RK3566/RK3568, 2 on RK3576, 3 on RK3588), so concurrent requests run on all cores at once. The contexts of a model share its weights, but each one needs its own working memory. Extra contexts therefore cost little for models that are mostly weights, such as CLIP, but for OCR detection each one can add close to the model's own memory again, more so at higher OCR resolutions. Set `MACHINE_LEARNING_RKNN_THREADS=1` to trade throughput for memory.
- Keep `MACHINE_LEARNING_WORKERS=1` unless you have a specific reason not to: each worker process still loads its own copy of the weights.
- Raise Immich job concurrency (Smart Search, Face Detection, OCR) a bit above the number of NPU cores, e.g. `4`–`6`, so a prepared image is always waiting when a core frees up.
## Setup
@@ -100,7 +102,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
You can confirm the device is being recognized and used by checking its utilization. There are many tools to display this, such as `nvtop` for NVIDIA or Intel, `intel_gpu_top` for Intel, and `radeontop` for AMD.
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), or a `Loaded ANN model` log entry without errors in the case of ARM NN.
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), a `Loaded ANN model` log entry without errors in the case of ARM NN, or a `Loaded RKNN model ... threads sharing its weights` log entry for RKNN.
#### Single Compose File
@@ -174,7 +176,7 @@ Note that you should increase job concurrencies to increase overall utilization
- Less heat generation
- Very slightly lower accuracy (RKNPU always uses FP16, while ARM NN by default uses higher precision FP32 unless `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled)
- Varying speed (tested on RK3588):
- If `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
- If `MACHINE_LEARNING_RKNN_THREADS` is set to 3, it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
- With a single context (`MACHINE_LEARNING_RKNN_THREADS=1`), RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
- With three contexts (the default on RK3588), it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
- When other tasks also use the GPU (like transcoding), RKNPU has a significant advantage over ARM NN as it uses the otherwise idle NPU instead of competing for GPU usage
- Lower RAM usage if `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, but significantly higher if greater than 1 (which is necessary for it to fully utilize the NPU and hence be comparable in speed to ARM NN)
- Lower RAM usage than earlier RKNN builds: the contexts of a model share its weights instead of each loading their own copy. Keep `MACHINE_LEARNING_WORKERS=1` and raise Immich ML job concurrency a bit above the core count so every NPU core stays busy.
+5
View File
@@ -209,6 +209,11 @@ The default configuration looks like this:
"timeout": 30000,
"tokenEndpointAuthMethod": "client_secret_post"
},
"passkey": {
"enabled": true,
"domain": null,
"additionalDomains": []
},
"passwordLogin": {
"enabled": true
},
+1 -1
View File
@@ -179,7 +179,7 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | `4` | 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_RKNN_THREADS` | Number of RKNN contexts per model, which share its weights | number of NPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| `MACHINE_LEARNING_MODEL_REVISION` | Model revision to download; `main` selects the original exports and `v2` selects the newer exports | `main` | machine learning |
+1 -1
View File
@@ -1,7 +1,7 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.137.0"
version = "4.152.0"
backend = "npm:wrangler"
[tools.wrangler.options]
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.137.0"
wrangler = "4.152.0"
+2 -2
View File
@@ -40,8 +40,8 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0",
"exiftool-vendored": "^35.0.0",
"eslint-plugin-unicorn": "^78.0.0",
"exiftool-vendored": "^39.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
+3 -9
View File
@@ -628,25 +628,19 @@ describe('/albums', () => {
]);
});
it('should not be able to remove foreign asset from foreign album', async () => {
it('should be able to remove foreign asset from shared album as an editor', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({
id: user1Asset1.id,
success: false,
error: 'no_permission',
}),
]);
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[0].id}/assets`)
.delete(`/albums/${user1Albums[2].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
+1 -1
View File
@@ -355,7 +355,7 @@ describe('/asset', () => {
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: asset.id });
expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
expect(body).toEqual(errorDto.badRequest('Not found or no asset.update access'));
expect(status).toEqual(400);
});
+2 -2
View File
@@ -567,7 +567,7 @@ describe('/search', () => {
'New York',
'Shanghai',
'State of Berlin',
'St.-Petersburg',
'St. Petersburg',
'Tbilisi',
'Tokyo',
'Virginia',
@@ -591,7 +591,7 @@ describe('/search', () => {
'New York',
'Shanghai',
'State of Berlin',
'St.-Petersburg',
'St. Petersburg',
'Tbilisi',
'Tokyo',
'Virginia',
@@ -206,6 +206,13 @@ describe('/shared-links', () => {
);
});
it('should not return album users', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
expect(status).toBe(200);
expect(body.album.albumUsers).toEqual([]);
});
it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(app)
.get('/shared-links/me')
-40
View File
@@ -1,40 +0,0 @@
import { LoginResponseDto, getMyUser, login } from '@immich/sdk';
import { createUserDto } from 'src/fixtures.js';
import { app, asBearerAuth, utils } from 'src/utils.js';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/users', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2);
});
describe('PUT /users/me', () => {
/** @deprecated */
it('should allow a user to change their password (deprecated)', async () => {
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
expect(user.shouldChangePassword).toBe(true);
const { status, body } = await request(app)
.put(`/users/me`)
.send({ password: 'super-secret' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
email: nonAdmin.userEmail,
shouldChangePassword: false,
});
const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } });
expect(token.accessToken).toBeDefined();
});
});
});
+4 -3
View File
@@ -28,7 +28,7 @@ test.describe('Registration', () => {
await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('admin@immich.app');
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Login', exact: true }).click();
// onboarding
await expect(page).toHaveURL('/auth/onboarding');
@@ -66,11 +66,12 @@ test.describe('Registration', () => {
await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Login', exact: true }).click();
// change password
await expect(page.getByRole('heading')).toHaveText('Change Password');
await expect(page).toHaveURL('/auth/change-password');
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByLabel('New Password').fill('new-password');
await page.getByLabel('Confirm Password').fill('new-password');
await page.getByRole('button', { name: 'Change password' }).click();
@@ -79,7 +80,7 @@ test.describe('Registration', () => {
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Login', exact: true }).click();
// onboarding
await expect(page).toHaveURL('/auth/onboarding');
+1 -1
View File
@@ -103,7 +103,7 @@ test.describe('Shared Links', () => {
test('show error for invalid shared link', async ({ page }) => {
await page.goto('/share/invalid');
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
await page.getByRole('heading', { name: 'Page not found' }).waitFor();
});
test('auth on navigation from shared link to timeline', async ({ context, page }) => {
@@ -14,9 +14,7 @@ type DayDistributionStrategy = (
rng: SeededRandom,
) => MockTimelineAsset[];
/**
* Strategies for determining total asset count per month
*/
/** 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
@@ -25,9 +23,7 @@ export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStra
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
};
/**
* Strategies for distributing assets across days within a month
*/
/** 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
@@ -1,6 +1,4 @@
/**
* Generator functions for timeline model objects
*/
/** Generator functions for timeline model objects */
import { faker } from '@faker-js/faker';
import { AssetVisibility } from '@immich/sdk';
@@ -12,9 +10,7 @@ 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
*/
/** Generate a random aspect ratio based on weighted probabilities */
export function generateAspectRatio(rng: SeededRandom): string {
const random = rng.next();
let cumulative = 0;
@@ -89,9 +85,7 @@ export function generateAsset(
return asset;
}
/**
* Generate assets for a specific day
*/
/** Generate assets for a specific day */
export function generateDayAssets(
year: number,
month: number,
@@ -104,9 +98,9 @@ export function generateDayAssets(
}
/**
* Distribute assets evenly across consecutive days
*
* @returns Array of generated timeline assets
Distribute assets evenly across consecutive days
@returns Array of generated timeline assets
*/
export function generateConsecutiveDays(
year: number,
@@ -133,9 +127,7 @@ export function generateConsecutiveDays(
return assets;
}
/**
* Generate assets for a month with specified distribution pattern
*/
/** Generate assets for a month with specified distribution pattern */
export function generateMonthAssets(
year: number,
month: number,
@@ -173,9 +165,7 @@ export function generateMonthAssets(
return assets;
}
/**
* Main generator function for timeline data
*/
/** Main generator function for timeline data */
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
validateTimelineConfig(config);
+24 -28
View File
@@ -1,6 +1,4 @@
/**
* REST API output functions for converting timeline data to API response formats
*/
/** REST API output functions for converting timeline data to API response formats */
import {
AlbumUserRole,
@@ -19,9 +17,7 @@ import { signupDto } from 'src/fixtures.js';
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
/**
* Convert timeline/asset models to columnar format (parallel arrays)
*/
/** Convert timeline/asset models to columnar format (parallel arrays) */
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
const result: TimeBucketAssetResponseDto = {
id: [],
@@ -70,10 +66,10 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
}
/**
* 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
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,
@@ -141,15 +137,15 @@ export type Changes = {
};
/**
* 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
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,
@@ -175,8 +171,8 @@ function shouldIncludeAsset(
return isFavorite === undefined || actuallyFavorited === isFavorite;
}
/**
* Get summary for all buckets (mimics getTimeBuckets API)
* When albumId is provided, only includes buckets that contain assets from that album
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,
@@ -279,8 +275,8 @@ const createDefaultOwner = (ownerId: string) => {
};
/**
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
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();
@@ -292,7 +288,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
make: null,
model: null,
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
exifImageHeight: Math.round(asset.ratio > 1 ? 4000 : 3000 / asset.ratio),
fileSizeInByte: asset.fileSizeInByte,
orientation: '1',
dateTimeOriginal: asset.fileCreatedAt,
@@ -348,8 +344,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
}
/**
* Get a single asset by ID from timeline data
* This matches the response from GET /api/assets/:id
Get a single asset by ID from timeline data
This matches the response from GET /api/assets/:id
*/
export function getAsset(
timelineData: MockTimelineData,
@@ -368,8 +364,8 @@ export function getAsset(
}
/**
* Get a mock album from timeline data
* This matches the response from GET /api/albums/:id
Get a mock album from timeline data
This matches the response from GET /api/albums/:id
*/
export function getAlbum(
timelineData: MockTimelineData,
@@ -57,9 +57,7 @@ export type MonthSpec = {
pattern: DayPattern;
};
/**
* Configuration for timeline data generation
*/
/** Configuration for timeline data generation */
export type TimelineConfig = {
ownerId?: string;
months: MonthSpec[];
@@ -88,9 +86,7 @@ export type SerializedTimelineData = {
album: MockAlbum;
};
/**
* Validates a TimelineConfig object to ensure all values are within expected ranges
*/
/** 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');
@@ -146,9 +142,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
}
}
/**
* Create a default timeline configuration
*/
/** Create a default timeline configuration */
export function createDefaultTimelineConfig(): TimelineConfig {
const months: MonthSpec[] = [
// 2024 - Mix of patterns
+17 -28
View File
@@ -1,9 +1,7 @@
import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
/**
* Linear Congruential Generator for deterministic pseudo-random numbers
*/
/** Linear Congruential Generator for deterministic pseudo-random numbers */
export class SeededRandom {
private seed: number;
@@ -11,37 +9,31 @@ export class SeededRandom {
this.seed = seed;
}
/**
* Generate next random number in range [0, 1)
*/
/** 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)
*/
/** 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
*/
/** 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
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>();
@@ -64,9 +56,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
return [...selectedDays].toSorted((a, b) => b - a);
}
/**
* Select item from array using seeded random
*/
/** 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');
@@ -75,9 +65,7 @@ export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
return arr[index];
}
/**
* Select multiple random items from array using seeded random without duplicates
*/
/** 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');
@@ -104,10 +92,10 @@ export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRand
}
/**
* 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"
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) {
@@ -150,6 +138,7 @@ export function getMockAsset(
return null;
}
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
+22
View File
@@ -1,5 +1,6 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'src/../playwright.config.js';
import { adminConfig, publicConfig, userConfig } from 'src/ui/mock-network/config-fixtures.js';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
@@ -113,6 +114,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
},
});
});
await context.route('**/api/public/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: publicConfig,
});
});
await context.route('**/api/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: userConfig,
});
});
await context.route('**/api/admin/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: adminConfig,
});
});
await context.route('**/api/server/features', async (route) => {
return route.fulfill({
status: 200,
+171
View File
@@ -0,0 +1,171 @@
export const publicConfig = {
ffmpeg: { realtime: { enabled: false } },
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false },
passkey: { enabled: true },
passwordLogin: { enabled: true },
server: { loginPageMessage: '' },
};
export const userConfig = {
ffmpeg: { realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] } },
machineLearning: {
enabled: false,
clip: { enabled: true },
duplicateDetection: { enabled: true },
facialRecognition: { enabled: true, minFaces: 3 },
ocr: { enabled: true },
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false, accountManagementUrl: '' },
passkey: { enabled: true },
passwordLogin: { enabled: true },
image: { thumbnail: { size: 250 }, preview: { size: 1440 }, fullsize: { enabled: false } },
trash: { enabled: true, days: 30 },
notifications: { smtp: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
user: { deleteDelay: 7 },
};
export const adminConfig = {
backup: { database: { enabled: true, cronExpression: '0 02 * * *', keepLastAmount: 14 } },
ffmpeg: {
crf: 23,
threads: 0,
preset: 'ultrafast',
targetVideoCodec: 'h264',
acceptedVideoCodecs: ['h264'],
targetAudioCodec: 'aac',
acceptedAudioCodecs: ['aac', 'mp3', 'opus'],
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: true,
realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] },
},
integrityChecks: {
missingFiles: { enabled: true, cronExpression: '0 03 * * *' },
untrackedFiles: { enabled: true, cronExpression: '0 03 * * *' },
checksumFiles: { enabled: true, cronExpression: '0 03 * * *', timeLimit: 3_600_000, percentageLimit: 1 },
},
job: {
thumbnailGeneration: { concurrency: 3 },
metadataExtraction: { concurrency: 5 },
videoConversion: { concurrency: 1 },
faceDetection: { concurrency: 2 },
smartSearch: { concurrency: 2 },
backgroundTask: { concurrency: 5 },
migration: { concurrency: 5 },
search: { concurrency: 5 },
sidecar: { concurrency: 5 },
library: { concurrency: 5 },
notifications: { concurrency: 5 },
ocr: { concurrency: 1 },
workflow: { concurrency: 5 },
editor: { concurrency: 2 },
integrityCheck: { concurrency: 1 },
},
logging: { enabled: true, level: 'log' },
machineLearning: {
enabled: false,
urls: ['http://immich-machine-learning:3003'],
availabilityChecks: { enabled: true, timeout: 2000, interval: 30_000 },
clip: { enabled: true, modelName: 'ViT-B-32__openai' },
duplicateDetection: { enabled: true, maxDistance: 0.01 },
facialRecognition: { enabled: true, modelName: 'buffalo_l', minScore: 0.7, maxDistance: 0.5, minFaces: 3 },
ocr: {
enabled: true,
modelName: 'PP-OCRv5_mobile',
minDetectionScore: 0.5,
minRecognitionScore: 0.8,
maxResolution: 736,
},
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
metadata: { faces: { import: false } },
oauth: {
autoLaunch: false,
autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
accountManagementUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
tokenEndpointAuthMethod: 'client_secret_post',
timeout: 30_000,
allowInsecureRequests: false,
},
passkey: { enabled: true, domain: null, additionalDomains: [] },
passwordLogin: { enabled: true },
storageTemplate: {
enabled: false,
hashVerificationEnabled: true,
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
image: {
thumbnail: { format: 'webp', size: 250, quality: 80, progressive: false },
preview: { format: 'jpeg', size: 1440, quality: 80, progressive: false },
colorspace: 'p3',
extractEmbedded: false,
fullsize: { enabled: false, format: 'jpeg', quality: 80, progressive: false },
},
newVersionCheck: { enabled: true, channel: 'stable' },
nightlyTasks: {
startTime: '00:00',
databaseCleanup: true,
generateMemories: true,
syncQuotaUsage: true,
missingThumbnails: true,
clusterNewFaces: true,
},
trash: { enabled: true, days: 30 },
theme: { customCss: '' },
library: { scan: { enabled: true, cronExpression: '0 0 * * *' }, watch: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
notifications: {
smtp: {
enabled: false,
from: '',
replyTo: '',
transport: { ignoreCert: false, host: '', port: 587, secure: false, username: '', password: '' },
},
},
templates: { email: { welcomeTemplate: '', albumInviteTemplate: '', albumUpdateTemplate: '' } },
user: { deleteDelay: 7 },
};
@@ -100,7 +100,7 @@ test.describe('OCR with stacked assets', () => {
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
await page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`).click();
// refreshOcr() clears showOverlay when switching assets, so re-enable it
await expect(ocrBoxes).toHaveCount(0);
@@ -242,6 +242,7 @@ test.describe('OCR text interaction', () => {
return;
}
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const createTouch = (id: number, x: number, y: number) => {
return new Touch({
identifier: id,
@@ -75,8 +75,8 @@ test.describe('asset-viewer stack', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const secondAsset = page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`);
await secondAsset.click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
@@ -150,9 +150,9 @@ test.describe('Timeline', () => {
});
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.
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);
+5 -5
View File
@@ -270,13 +270,13 @@ export const utils = {
}
},
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => {
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms = 10_000 }: WaitOptions): Promise<void> => {
return new Promise<void>((resolve, reject) => {
if (!id && !count) {
reject(new Error('id or count must be provided for waitForWebsocketEvent'));
}
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms);
const type = id ? `id=${id}` : `count=${count}`;
console.log(`Waiting for ${event} [${type}]`);
const set = events[event];
@@ -550,7 +550,7 @@ export const utils = {
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await
// eslint-disable-next-line unicorn/prefer-await
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
@@ -660,10 +660,10 @@ export const utils = {
return !jobCounts.active && (!jobCounts.waiting || queues[queue].queueStatus.isPaused);
},
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms: number = 15_000) => {
// 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 || 15_000);
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms);
while (true) {
const done = await utils.isQueueEmpty(accessToken, queue);
+25 -3
View File
@@ -87,7 +87,7 @@
"failed_job_command": "Command {command} failed for job: {job}",
"force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.",
"image_format": "Format",
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.",
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode. JPEG XL is the best option for high-quality compression, but not all browsers and apps can display it.",
"image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in",
"image_fullsize_enabled": "Enable full-size image generation",
"image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.",
@@ -101,7 +101,7 @@
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
"image_preview_title": "Preview Settings",
"image_progressive": "Progressive",
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on other formats.",
"image_quality": "Quality",
"image_resolution": "Resolution",
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
@@ -324,6 +324,13 @@
"oauth_timeout": "Request Timeout",
"oauth_timeout_description": "Timeout for requests in milliseconds",
"ocr_job_description": "Use machine learning to recognize text in images",
"passkey_additional_domains": "Additional domains",
"passkey_additional_domains_description": "Additional, trusted domains (related origin requests). These will only work if the passkey domain is also accessible.",
"passkey_domain": "Passkey domain",
"passkey_domain_description": "Domain used for passkeys (relying party). Leave blank to infer from the origin header. Changing this value will result in existing passkeys no longer working.",
"passkey_enable_description": "Login with a passkey",
"passkey_settings": "Passkey Login",
"passkey_settings_description": "Manage passkey login settings",
"password_enable_description": "Login with email and password",
"password_settings": "Password Login",
"password_settings_description": "Manage password login settings",
@@ -874,6 +881,8 @@
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
"delete_local_dialog_ok_force": "Delete Anyway",
"delete_others": "Delete others",
"delete_passkey": "Delete passkey",
"delete_passkey_prompt": "Are you sure you want to delete this passkey?",
"delete_permanently": "Delete permanently",
"delete_permanently_action_prompt": "{count} deleted permanently",
"delete_shared_link": "Delete shared link",
@@ -943,6 +952,7 @@
"edit_location_action_prompt": "{count} location edited",
"edit_location_dialog_title": "Location",
"edit_name": "Edit name",
"edit_passkey": "Edit passkey",
"edit_people": "Edit people",
"edit_person": "Edit person",
"edit_tag": "Edit tag",
@@ -1287,6 +1297,7 @@
"last": "Last",
"last_months": "{count, plural, one {Last month} other {Last # months}}",
"last_seen": "Last seen",
"last_used": "Last used",
"latest_version": "Latest Version",
"latitude": "Latitude",
"leave": "Leave",
@@ -1379,6 +1390,7 @@
"login_has_been_disabled": "Login has been disabled",
"login_password_changed_error": "There was an error updating your password",
"login_password_changed_success": "Password updated successfully",
"login_with_passkey": "Login with passkey",
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
"logs": "Logs",
@@ -1427,6 +1439,7 @@
"manage_your_api_keys": "Manage your API keys",
"manage_your_devices": "Manage your logged-in devices",
"manage_your_oauth_connection": "Manage your OAuth connection",
"manage_your_passkeys": "Manage your passkeys",
"map": "Map",
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
"map_cannot_get_user_location": "Cannot get user's location",
@@ -1546,6 +1559,7 @@
"new_album": "New Album",
"new_api_key": "New API Key",
"new_feature": "New Feature",
"new_passkey": "New passkey",
"new_password": "New password",
"new_person": "New person",
"new_pin_code": "New PIN code",
@@ -1658,6 +1672,8 @@
"partner_sharing": "Partner Sharing",
"partners": "Partners",
"partners_description": "Share your photos and videos with other users",
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
"passkeys_not_supported": "Passkeys are not supported by this browser",
"password": "Password",
"password_does_not_match": "Password does not match",
"password_required": "Password Required",
@@ -1842,6 +1858,7 @@
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
"removed_from_stack": "Removed asset from stack",
"removed_memory": "Removed memory",
"removed_passkey": "Removed passkey: {name}",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
"repository": "Repository",
@@ -2025,6 +2042,7 @@
"share_everyone": "Share everyone",
"share_everyone_description": "Give {user} access to all of your people.",
"share_link": "Share Link",
"share_logs": "Share Logs",
"share_original": "Use original (large)",
"share_preview": "Use preview (small)",
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
@@ -2203,6 +2221,8 @@
"theme": "Theme",
"theme_selection": "Theme selection",
"theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Scroll continuously when dragging the scrollbar instead of snapping to months",
"theme_setting_asset_list_smooth_scrubbing_title": "Smooth timeline scrubbing",
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})",
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
@@ -2243,6 +2263,8 @@
"trigger": "Trigger",
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
"trigger_asset_ocr_extraction": "Asset OCR Extraction",
"trigger_asset_ocr_extraction_description": "Triggered when the OCR text of an asset is extracted",
"trigger_asset_tagged": "Asset Tagged",
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
"trigger_asset_uploaded": "Asset Upload",
@@ -2318,7 +2340,7 @@
"user": "User",
"user_has_been_deleted": "This user has been deleted.",
"user_id": "User ID",
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
"user_liked": "{user} liked {type, select, image {this photo} video {this video} other {this}}",
"user_pin_code_settings": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code",
"user_privacy": "User Privacy",
+20
View File
@@ -323,6 +323,13 @@
"oauth_timeout": "Таймаут для запросов",
"oauth_timeout_description": "Максимальное время, в течение которого ожидать ответа, в миллисекундах",
"ocr_job_description": "Использование машинного обучения для распознавания текста на изображениях",
"passkey_additional_domains": "Дополнительные домены",
"passkey_additional_domains_description": "Дополнительные доверенные домены. Для их работы основной домен ключей доступа также должен быть доступен.",
"passkey_domain": "Домен ключей доступа",
"passkey_domain_description": "Домен для ключей доступа. Оставьте пустым для определения по адресу сайта. При изменении домена существующие ключи перестанут работать.",
"passkey_enable_description": "Вход с помощью ключа доступа",
"passkey_settings": "Вход по ключу доступа",
"passkey_settings_description": "Настройки входа по ключу доступа",
"password_enable_description": "Вход по электронной почте и паролю",
"password_settings": "Настройки входа с паролем",
"password_settings_description": "Управление настройками входа по паролю",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии",
"delete_local_dialog_ok_force": "Все равно удалить",
"delete_others": "Удалить остальные",
"delete_passkey": "Удалить ключ доступа",
"delete_passkey_prompt": "Удалить этот ключ доступа?",
"delete_permanently": "Удалить навсегда",
"delete_permanently_action_prompt": "Объекты удалены безвозвратно ({count} шт.)",
"delete_shared_link": "Удалить публичную ссылку",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "Места изменены ({count} шт.)",
"edit_location_dialog_title": "Местоположение",
"edit_name": "Изменить имя",
"edit_passkey": "Изменить ключ доступа",
"edit_people": "Изменить людей",
"edit_person": "Изменить человека",
"edit_tag": "Изменить тег",
@@ -1287,6 +1297,7 @@
"last": "Последний",
"last_months": "{count, plural, one {Последний месяц} many {# последних месяцев} other {# последних месяца}}",
"last_seen": "Последний доступ",
"last_used": "Последнее использование",
"latest_version": "Последняя версия",
"latitude": "Широта",
"leave": "Покинуть",
@@ -1379,6 +1390,7 @@
"login_has_been_disabled": "Вход в систему был отключён",
"login_password_changed_error": "Произошла ошибка при обновлении пароля",
"login_password_changed_success": "Пароль успешно обновлен",
"login_with_passkey": "Войти с ключом доступа",
"logout_all_device_confirmation": "Вы действительно хотите завершить все сеансы, кроме текущего?",
"logout_this_device_confirmation": "Вы действительно хотите завершить сеанс на этом устройстве?",
"logs": "Журнал событий",
@@ -1427,6 +1439,7 @@
"manage_your_api_keys": "Управление API ключами для взаимодействия с другими программами",
"manage_your_devices": "Управление устройствами, на которых вы входили в свой аккаунт",
"manage_your_oauth_connection": "Настройки подключённого OAuth",
"manage_your_passkeys": "Управление ключами доступа",
"map": "Карта",
"map_assets_in_bounds": "{count, plural, =0 {Нет фото в этой области} other {# фото}}",
"map_cannot_get_user_location": "Невозможно получить местоположение пользователя",
@@ -1546,6 +1559,7 @@
"new_album": "Новый альбом",
"new_api_key": "Новый API ключ",
"new_feature": "Новая функциональность",
"new_passkey": "Новый ключ доступа",
"new_password": "Новый пароль",
"new_person": "Новый человек",
"new_pin_code": "Новый PIN-код",
@@ -1658,6 +1672,8 @@
"partner_sharing": "Совместное использование",
"partners": "Партнёры",
"partners_description": "Делитесь своими фото и видео с другими пользователями",
"passkeys": "{count, plural, one {Ключ доступа} few {Ключи доступа} many {Ключи доступа} other {Ключи доступа}}",
"passkeys_not_supported": "Этот браузер не поддерживает ключи доступа",
"password": "Пароль",
"password_does_not_match": "Пароли не совпадают",
"password_required": "Требуется пароль",
@@ -1842,6 +1858,7 @@
"removed_from_favorites_count": "{count, plural, one {# объект удалён} many {# объектов удалено} other {# объекта удалено}} из избранного",
"removed_from_stack": "Объект удалён из группы",
"removed_memory": "Воспоминание удалено",
"removed_passkey": "Удалён ключ доступа: {name}",
"removed_tagged_assets": "Тег удалён у {count, plural, one {# объекта} other {# объектов}}",
"rename": "Переименовать",
"repository": "Репозиторий",
@@ -2027,6 +2044,7 @@
"share_everyone": "Поделиться со всеми",
"share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.",
"share_link": "Создать ссылку",
"share_logs": "Поделиться журналами",
"share_original": "Оригинал (большой размер)",
"share_preview": "Предпросмотр (небольшой размер)",
"share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.",
@@ -2206,6 +2224,8 @@
"theme": "Тема",
"theme_selection": "Выбор темы",
"theme_selection_description": "Автоматически устанавливать светлую или тёмную тему в зависимости от настроек вашего браузера",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Прокручивать ленту плавно при перетаскивании полосы прокрутки, без переходов по месяцам",
"theme_setting_asset_list_smooth_scrubbing_title": "Плавная прокрутка ленты",
"theme_setting_asset_list_storage_indicator_title": "Отображать индикатор хранилища на плитках объектов",
"theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({count})",
"theme_setting_colorful_interface_subtitle": "Добавить оттенок к фону.",
+7 -7
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS builder-cpu
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-cpu
FROM python:3.13-slim-trixie@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b AS builder-openvino
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-openvino
# renovate: datasource=github-releases depName=microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.30.0"
@@ -59,7 +59,7 @@ RUN mkdir /opt/armnn && \
cd /opt/ann && \
sh build.sh
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS builder-rknn
FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
@@ -81,11 +81,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy && \
if [ "$DEVICE" = "openvino" ]; then uv pip install --no-deps /opt/onnxruntime_openvino-*.whl; fi
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-cpu
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-cpu
ENV MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
@@ -141,7 +141,7 @@ COPY --from=builder-armnn \
/opt/ann/build.sh \
/opt/armnn/
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS prod-rknn
FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS prod-rknn
# renovate: datasource=github-tags depName=airockchip/rknn-toolkit2
ARG RKNN_TOOLKIT_VERSION="v2.3.0"
@@ -150,7 +150,7 @@ ENV MACHINE_LEARNING_MODEL_ARENA=false
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS mimalloc
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS mimalloc
RUN echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/backports.list && \
apt-get update && \
apt-get install -y --no-install-recommends libmimalloc3/trixie-backports && \
+1 -1
View File
@@ -70,7 +70,7 @@ class Settings(BaseSettings):
ann_fp16_turbo: bool = False
ann_tuning_level: int = 2
rknn: bool = True
rknn_threads: int = 1
rknn_threads: int | None = None
preload: PreloadModelData | None = None
max_batch_size: MaxBatchSize = MaxBatchSize()
model_organization: ModelOrganization = ModelOrganization.APP
+3 -1
View File
@@ -3,6 +3,7 @@ from collections.abc import Hashable
from typing import Any
from immich_ml import allocator
from immich_ml.config import clean_name
from immich_ml.models.base import InferenceEntry, InferenceModel
from immich_ml.schemas import Options
@@ -15,7 +16,8 @@ class ModelCache:
self._expiries: dict[Key, asyncio.TimerHandle] = {}
def get[O: Options](self, entry: InferenceEntry[O], ttl: int | None = None) -> InferenceModel[O]:
key = (entry.model, entry.name, entry.model.graph(entry.options))
# names that differ only in what cleaning drops, like an `immich-app/` prefix, are the same model
key = (entry.model, clean_name(entry.name), entry.model.graph(entry.options))
if key not in self._models:
self._models[key] = entry.model.create(entry.name, entry.options)
elif key not in self._expiries:
@@ -100,8 +100,7 @@ SUPPORTED_PROVIDERS = [
"CPUExecutionProvider",
]
RKNN_SUPPORTED_SOCS = ["rk3566", "rk3568", "rk3576", "rk3588"]
RKNN_COREMASK_SUPPORTED_SOCS = ["rk3576", "rk3588"]
RKNN_NPU_CORES = {"rk3566": 1, "rk3568": 1, "rk3576": 2, "rk3588": 3}
WEBLATE_TO_FLORES200 = {
@@ -7,10 +7,14 @@ import numpy as np
import orjson
from numpy.typing import NDArray
from PIL import Image
from pillow_jxl import JpegXLImagePlugin
_PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling}
_PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation)
# Requests are already decoded in parallel, so don't start a thread per core for each one
JpegXLImagePlugin.DECODE_THREADS = 0
def resize_pil(img: Image.Image, size: int, resample: Image.Resampling = Image.Resampling.BICUBIC) -> Image.Image:
if img.width < img.height:
@@ -9,9 +9,10 @@ import orjson
from numpy.typing import NDArray
from immich_ml.config import log, settings
from immich_ml.models.constants import RKNN_NPU_CORES
from immich_ml.schemas import ModelInput, ModelTensor, SessionNode, Shape
from .rknnpool import RknnNode, RknnPoolExecutor, is_available, native_outputs, soc_name
from .rknnpool import RknnNode, RknnPoolExecutor, is_available, soc_name
is_available = is_available and settings.rknn
model_prefix = Path("rknpu") / soc_name if is_available and soc_name is not None else None
@@ -21,12 +22,6 @@ def model_path(model_dir: Path, variant: str = "") -> Path:
return (model_dir / model_prefix if model_prefix else model_dir) / variant / "model.rknn"
def run_inference(rknn_lite: Any, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
rknn_lite.rknn_runtime.set_inputs(inputs, None, data_format)
rknn_lite.rknn_runtime.run(False)
return native_outputs(rknn_lite)
def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
if array.ndim != 4:
return None # token ids carry no layout
@@ -39,11 +34,11 @@ def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
class RknnSession:
def __init__(self, model_path: Path) -> None:
self.tpe = settings.rknn_threads
self.tpe = settings.rknn_threads or RKNN_NPU_CORES.get(soc_name or "", 1)
log.info(f"Loading RKNN model from {model_path} with {self.tpe} threads.")
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe, func=run_inference)
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads.")
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe)
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads sharing its weights.")
# the shapes the binary was compiled for, among which the runtime routes itself
batch = self.rknnpool.inputs[0].shape[0]
declared = orjson.loads(self.rknnpool.custom_string) if self.rknnpool.custom_string else {}
@@ -77,7 +72,7 @@ class RknnSession:
input_feed: ModelInput,
run_options: Any = None,
) -> list[NDArray[np.float32]]:
inputs = [array if array.flags.c_contiguous else array.copy() for array in input_feed.values()]
inputs = list(input_feed.values())
return self.rknnpool.run(inputs, input_layout(self.rknnpool.inputs[0].shape, inputs[0]))
@@ -1,18 +1,15 @@
# This code is from leafqycc/rknn-multi-threaded
# Following Apache License 2.0
import ctypes
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from ctypes import byref, sizeof
from pathlib import Path
from typing import Any, Callable, NamedTuple
from queue import SimpleQueue
from typing import Any, NamedTuple
import numpy as np
from numpy.typing import NDArray
from immich_ml.config import log
from immich_ml.models.constants import RKNN_COREMASK_SUPPORTED_SOCS, RKNN_SUPPORTED_SOCS
from immich_ml.models.constants import RKNN_NPU_CORES
from immich_ml.schemas import ModelTensor
@@ -20,7 +17,7 @@ def get_soc(device_tree_path: Path | str) -> str | None:
try:
with Path(device_tree_path).open() as f:
device_compatible_str = f.read()
for soc in RKNN_SUPPORTED_SOCS:
for soc in RKNN_NPU_CORES:
if soc in device_compatible_str:
return soc
log.warning("Device is not supported for RKNN")
@@ -29,26 +26,34 @@ def get_soc(device_tree_path: Path | str) -> str | None:
return None
soc_name = None
is_available = False
try:
from rknnlite.api import RKNNLite, rknn_runtime
soc_name = get_soc("/proc/device-tree/compatible")
is_available = soc_name is not None
except ImportError:
log.debug("RKNN is not available")
class RknnNode(NamedTuple):
name: str
shape: tuple[int, ...]
_CUSTOM_STRING_QUERY = 7 # RKNN_QUERY_CUSTOM_STRING
# RKNN_QUERY_OUTPUT_ATTR, and RKNN_QUERY_CURRENT_OUTPUT_ATTR for the shape a dynamic binary last produced
_OUTPUT_QUERY, _CURRENT_OUTPUT_QUERY = 2, 15
_DTYPES = {0: np.float32, 1: np.float16} # rknn_tensor_type
# rknn_query_cmd
_IN_OUT_NUM, _INPUT_ATTR, _OUTPUT_ATTR, _CUSTOM_STRING = 0, 1, 2, 7
_INPUT_DYNAMIC_RANGE, _CURRENT_OUTPUT_ATTR = 13, 15
# rknn_tensor_format
_NCHW, _NHWC = 0, 1
# rknn_tensor_type, in enum order
_DTYPES: list[type[np.generic]] = [
np.float32,
np.float16,
np.int8,
np.uint8,
np.int16,
np.uint16,
np.int32,
np.uint32,
np.int64,
np.bool_,
]
_TYPES = {np.dtype(dtype): index for index, dtype in enumerate(_DTYPES)}
class _IoNum(ctypes.Structure):
_fields_ = [("n_input", ctypes.c_uint32), ("n_output", ctypes.c_uint32)]
class _CustomString(ctypes.Structure):
@@ -76,94 +81,194 @@ class _TensorAttr(ctypes.Structure): # rknn_tensor_attr
]
def native_outputs(rknn_lite: "RKNNLite") -> list[NDArray[Any]]:
"""The outputs in the precision the NPU computed them in, which RKNNLite would widen to fp32 on the CPU."""
runtime: Any = rknn_lite.rknn_runtime
count = runtime.get_in_out_num()[1]
buffers = (rknn_runtime.RKNNOutput * count)()
for index, buffer in enumerate(buffers):
buffer.index = index
if runtime.lib.rknn_outputs_get(runtime.context, count, buffers, None) != 0:
raise RuntimeError("RKNN inference failed!")
query = _CURRENT_OUTPUT_QUERY if runtime.is_dynamic_shape else _OUTPUT_QUERY
outputs = []
for buffer in buffers:
attr = _TensorAttr(index=buffer.index)
runtime.lib.rknn_query(runtime.context, query, ctypes.byref(attr), ctypes.sizeof(attr))
data = ctypes.cast(buffer.buf, ctypes.POINTER(ctypes.c_char * buffer.size)).contents
outputs.append(np.frombuffer(data, _DTYPES[attr.type]).reshape(attr.dims[: attr.n_dims]).copy())
runtime.lib.rknn_outputs_release(runtime.context, count, buffers)
return outputs
class _InputRange(ctypes.Structure):
_fields_ = [
("index", ctypes.c_uint32),
("shape_number", ctypes.c_uint32),
("fmt", ctypes.c_int),
("name", ctypes.c_char * 256),
("dyn_range", (ctypes.c_uint32 * 16) * 512),
("n_dims", ctypes.c_uint32),
]
def tensor_nodes(rknn_lite: "RKNNLite") -> tuple[list[RknnNode], list[RknnNode]]:
runtime = rknn_lite.rknn_runtime
n_inputs, n_outputs = runtime.get_in_out_num()
def node(index: int, is_output: bool) -> RknnNode:
attr = runtime.get_tensor_attr(index, is_output=is_output)
return RknnNode(attr.name.decode(), tuple(attr.dims[: attr.n_dims]))
return [node(i, False) for i in range(n_inputs)], [node(i, True) for i in range(n_outputs)]
class _Input(ctypes.Structure):
_fields_ = [
("index", ctypes.c_uint32),
("buf", ctypes.c_void_p),
("size", ctypes.c_uint32),
("pass_through", ctypes.c_uint8),
("type", ctypes.c_int),
("fmt", ctypes.c_int),
]
def custom_string(rknn_lite: "RKNNLite") -> str:
runtime: Any = rknn_lite.rknn_runtime
buffer = _CustomString()
# rknnlite declares argtypes on the shared handle, so pass plain values and let ctypes convert
runtime.lib.rknn_query(runtime.context, _CUSTOM_STRING_QUERY, ctypes.byref(buffer), ctypes.sizeof(buffer))
declared: str = buffer.string.decode()
return declared
class _Output(ctypes.Structure):
_fields_ = [
("want_float", ctypes.c_uint8),
("is_prealloc", ctypes.c_uint8),
("index", ctypes.c_uint32),
("buf", ctypes.c_void_p),
("size", ctypes.c_uint32),
]
def init_rknn(model_path: str) -> "RKNNLite":
if not is_available:
raise RuntimeError("rknn is not available!")
rknn_lite = RKNNLite()
rknn_lite.rknn_log.logger.setLevel(logging.ERROR)
ret = rknn_lite.load_rknn(model_path)
_Key = tuple[tuple[int, ...], ...]
_Spec = tuple[tuple[int, ...], np.dtype[Any]]
_context = ctypes.c_uint64 # rknn_context on 64-bit
_ctx_p = ctypes.POINTER(_context)
_u32 = ctypes.c_uint32
soc_name = None
is_available = False
try:
lib = ctypes.CDLL("librknnrt.so") # CDLL releases the GIL for each call, so contexts run in parallel
lib.rknn_init.argtypes = _ctx_p, ctypes.c_char_p, _u32, _u32, ctypes.c_void_p
lib.rknn_dup_context.argtypes = _ctx_p, _ctx_p
lib.rknn_destroy.argtypes = (_context,)
lib.rknn_query.argtypes = _context, ctypes.c_int, ctypes.c_void_p, _u32
lib.rknn_set_input_shapes.argtypes = _context, _u32, ctypes.POINTER(_TensorAttr)
lib.rknn_inputs_set.argtypes = _context, _u32, ctypes.POINTER(_Input)
lib.rknn_run.argtypes = _context, ctypes.c_void_p
lib.rknn_outputs_get.argtypes = _context, _u32, ctypes.POINTER(_Output), ctypes.c_void_p
lib.rknn_outputs_release.argtypes = _context, _u32, ctypes.POINTER(_Output)
soc_name = get_soc("/proc/device-tree/compatible")
is_available = soc_name is not None
except OSError:
log.debug("RKNN is not available")
def _check(ret: int, call: str) -> None:
if ret != 0:
raise RuntimeError("Failed to load RKNN model")
raise RuntimeError(f"{call} failed with RKNN error {ret}")
if soc_name in RKNN_COREMASK_SUPPORTED_SOCS:
ret = rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO)
else:
ret = rknn_lite.init_runtime() # Please do not set this parameter on other platforms.
if ret != 0:
raise RuntimeError("Failed to initialize RKNN runtime environment")
def _query(ctx: int, cmd: int, info: ctypes.Structure) -> None:
_check(lib.rknn_query(ctx, cmd, byref(info), sizeof(info)), "rknn_query")
return rknn_lite
def _attr(ctx: int, cmd: int, index: int) -> _TensorAttr:
attr = _TensorAttr(index=index)
_query(ctx, cmd, attr)
return attr
def _specs(attrs: list[_TensorAttr]) -> list[_Spec]:
return [(tuple(attr.dims[: attr.n_dims]), np.dtype(_DTYPES[attr.type])) for attr in attrs]
class RknnPoolExecutor:
def __init__(
self,
model_path: str,
tpes: int,
func: Callable[["RKNNLite", list[ModelTensor], str | None], list[NDArray[np.float32]]],
) -> None:
self.tpes = tpes
self.rknn_pool = [init_rknn(model_path) for _ in range(tpes)]
self.inputs, self.outputs = tensor_nodes(self.rknn_pool[0])
self.custom_string = custom_string(self.rknn_pool[0])
self.pool = ThreadPoolExecutor(max_workers=tpes)
self.func = func
self.num = 0
self.lock = threading.Lock()
"""One context per thread, all but the first duplicated from it so that they share its weights.
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
with self.lock:
idx = self.num % self.tpes
self.num += 1
The model is loaded by path rather than from a buffer, so the runtime holds the only copy of it."""
fut = self.pool.submit(self.func, self.rknn_pool[idx], inputs, data_format)
return fut.result()
def __init__(self, model_path: str, tpes: int) -> None:
self.contexts: list[int] = []
self.executor = ThreadPoolExecutor(tpes, thread_name_prefix="rknn")
master = _context()
_check(lib.rknn_init(byref(master), model_path.encode(), 0, 0, None), "rknn_init")
self.contexts.append(master.value)
for _ in range(tpes - 1):
child = _context()
_check(lib.rknn_dup_context(byref(master), byref(child)), "rknn_dup_context")
self.contexts.append(child.value)
self.idle: SimpleQueue[int] = SimpleQueue()
for ctx in self.contexts:
self.idle.put(ctx)
io = _IoNum()
_query(master.value, _IN_OUT_NUM, io)
self.input_attrs = [_attr(master.value, _INPUT_ATTR, i) for i in range(io.n_input)]
self.output_attrs = [_attr(master.value, _OUTPUT_ATTR, i) for i in range(io.n_output)]
self.inputs = [RknnNode(a.name.decode(), tuple(a.dims[: a.n_dims])) for a in self.input_attrs]
self.outputs = [RknnNode(a.name.decode(), tuple(a.dims[: a.n_dims])) for a in self.output_attrs]
custom = _CustomString()
lib.rknn_query(master.value, _CUSTOM_STRING, byref(custom), sizeof(custom))
self.custom_string = custom.string.decode()
ranges = [_InputRange(index=index) for index in range(io.n_input)]
for rng in ranges:
lib.rknn_query(master.value, _INPUT_DYNAMIC_RANGE, byref(rng), sizeof(rng))
self.formats = [rng.fmt for rng in ranges]
self.compiled = [{tuple(rng.dyn_range[i][: rng.n_dims]) for i in range(rng.shape_number)} for rng in ranges]
self.dynamic = any(self.compiled)
self.shape_attrs: dict[_Key, ctypes.Array[_TensorAttr]] = {}
self.output_specs: dict[_Key, list[_Spec]] = {(): _specs(self.output_attrs)}
self.current: dict[int, _Key] = {}
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
return self.executor.submit(self._next, inputs, data_format).result()
def _next(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
ctx = self.idle.get()
try:
return self._run(ctx, inputs, data_format)
finally:
self.idle.put(ctx)
def _run(self, ctx: int, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
fed: list[NDArray[Any]] = [
np.ascontiguousarray(array.transpose(0, 2, 3, 1) if data_format == "nchw" and array.ndim == 4 else array)
for array in inputs
]
key = self._set_shapes(ctx, fed) if self.dynamic else ()
feed = (_Input * len(fed))(
*(
_Input(index=i, buf=array.ctypes.data, size=array.nbytes, type=_TYPES[array.dtype], fmt=_NHWC)
for i, array in enumerate(fed)
)
)
_check(lib.rknn_inputs_set(ctx, len(fed), feed), "rknn_inputs_set")
_check(lib.rknn_run(ctx, None), "rknn_run")
specs = self.output_specs.get(key)
if specs is None:
attrs = [_attr(ctx, _CURRENT_OUTPUT_ATTR, i) for i in range(len(self.output_attrs))]
specs = self.output_specs[key] = _specs(attrs)
outputs = [np.empty(dims, dtype) for dims, dtype in specs]
buffers = (_Output * len(outputs))(
*(
_Output(is_prealloc=1, index=i, buf=array.ctypes.data, size=array.nbytes)
for i, array in enumerate(outputs)
)
)
_check(lib.rknn_outputs_get(ctx, len(outputs), buffers, None), "rknn_outputs_get")
lib.rknn_outputs_release(ctx, len(outputs), buffers)
return outputs
def _set_shapes(self, ctx: int, inputs: list[NDArray[Any]]) -> _Key:
key = tuple(
self._compiled_shape(fmt, compiled, array.shape)
for fmt, compiled, array in zip(self.formats, self.compiled, inputs)
)
if self.current.get(ctx) == key:
return key
attrs = self.shape_attrs.get(key)
if attrs is None:
attrs = self.shape_attrs[key] = (_TensorAttr * len(key))()
for i, (source, compiled, fmt, shape) in enumerate(zip(self.input_attrs, self.compiled, self.formats, key)):
attrs[i] = _TensorAttr.from_buffer_copy(source)
if compiled:
attrs[i].n_dims = len(shape)
attrs[i].dims[: len(shape)] = shape
attrs[i].fmt = fmt
_check(lib.rknn_set_input_shapes(ctx, len(key), attrs), "rknn_set_input_shapes")
self.current[ctx] = key
return key
@staticmethod
def _compiled_shape(fmt: int, compiled: set[tuple[int, ...]], fed: tuple[int, ...]) -> tuple[int, ...]:
shape = (fed[0], fed[3], fed[1], fed[2]) if len(fed) == 4 and fmt == _NCHW else fed
if compiled and shape not in compiled:
raise ValueError(f"binary was compiled for {sorted(compiled)}, fed {list(fed)}")
return shape
def release(self) -> None:
self.pool.shutdown()
for rknn_lite in self.rknn_pool:
rknn_lite.release()
self.executor.shutdown()
for ctx in reversed(self.contexts): # duplicates before the context whose weights they share
lib.rknn_destroy(ctx)
self.contexts = []
def __del__(self) -> None:
self.release()
+4 -3
View File
@@ -14,7 +14,8 @@ dependencies = [
"onnx>=1.22.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<13",
"pillow>=13,<14",
"pillow-jxl-plugin>=1.3.8,<2.0",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",
@@ -56,7 +57,7 @@ cuda = [
]
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rknn = ["onnxruntime>=1.23.2,<2"]
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
[tool.uv]
@@ -64,7 +65,7 @@ compile-bytecode = true
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
[tool.uv.sources]
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "8bf3fd1b44b0c8608f52fb6e50ddff52826576d8" }
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "a1eeff2c2118d76067efecf4cc4630998eac519a" }
onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
openvino = { index = "openvino-nightly" }
+260 -4
View File
@@ -1,3 +1,4 @@
import ctypes
import json
import os
import pickle
@@ -63,8 +64,9 @@ from immich_ml.schemas import (
from immich_ml.sessions.ann import AnnSession
from immich_ml.sessions.ort import Device, GraphSpec, OrtSession, flush_denormals, fresh, prepared
from immich_ml.sessions.policy import ShapePolicy, batches, runs
from immich_ml.sessions.rknn import RknnSession, run_inference
from immich_ml.sessions.rknn import RknnSession
from immich_ml.sessions.rknn import model_path as rknn_model_path
from immich_ml.sessions.rknn.rknnpool import RknnPoolExecutor
class TestBase:
@@ -960,9 +962,11 @@ class TestRknnSession:
mocker.patch("immich_ml.sessions.rknn.is_available", True)
RknnSession(model_path)
rknn_session.assert_called_once_with(model_path=model_path.as_posix(), tpes=tpe, func=run_inference)
rknn_session.assert_called_once_with(model_path=model_path.as_posix(), tpes=tpe)
info.assert_has_calls([mock.call(f"Loaded RKNN model from {model_path} with {tpe} threads.")])
info.assert_has_calls(
[mock.call(f"Loaded RKNN model from {model_path} with {tpe} threads sharing its weights.")]
)
def test_run_rknn(self, rknn_session: mock.Mock, mocker: MockerFixture) -> None:
rknn_session.return_value.load.return_value = 123
@@ -974,7 +978,20 @@ class TestRknnSession:
session.run(None, input_feed)
rknn_session.return_value.run.assert_called_once_with([input1, input2], "nchw")
assert all(fed.flags.c_contiguous for fed in rknn_session.return_value.run.call_args.args[0])
@pytest.mark.parametrize(
("soc", "threads", "expected"),
[("rk3566", None, 1), ("rk3576", None, 2), ("rk3588", None, 3), ("rk3588", 2, 2)],
)
def test_uses_a_context_per_npu_core_unless_set(
self, soc: str, threads: int | None, expected: int, rknn_session: mock.Mock, mocker: MockerFixture
) -> None:
mocker.patch("immich_ml.sessions.rknn.soc_name", soc)
mocker.patch.object(settings, "rknn_threads", threads)
RknnSession(Path("ViT-B-32__openai"))
assert rknn_session.call_args.kwargs["tpes"] == expected
def test_run_rknn_rejects_a_shape_the_binary_was_not_compiled_for(
self, rknn_session: mock.Mock, mocker: MockerFixture
@@ -1011,6 +1028,237 @@ class TestRknnSession:
assert RknnSession(Path("buffalo_l")).shapes == (Shape(batch=1),)
class FakeRknn:
"""librknnrt for one input and one output, writing through the pointers as the runtime does."""
def __init__(self, ranges: list[tuple[int, ...]] | None = None) -> None:
self.ranges = ranges
self.contexts = 0
self.fed: list[tuple[int, int, int]] = [] # (context, type, fmt) per run
self.data: list[bytes] = []
self.buffers: list[int] = []
self.output_queries = 0
self.set_shapes: list[tuple[int, tuple[int, ...]]] = []
self.rknn_destroy = mock.Mock(return_value=0)
self.rknn_outputs_release = mock.Mock(return_value=0)
self.rknn_run = mock.Mock(return_value=0)
def rknn_init(self, ctx: Any, model: bytes, size: int, flag: int, extend: None) -> int:
self.contexts += 1
ctx._obj.value = self.contexts
return 0
def rknn_dup_context(self, ctx_in: Any, ctx_out: Any) -> int:
self.contexts += 1
ctx_out._obj.value = self.contexts
return 0
def rknn_query(self, ctx: int, cmd: int, info: Any, size: int) -> int:
obj = info._obj
if cmd == 0:
obj.n_input, obj.n_output = 1, 1
elif cmd == 1:
self._fill(obj, b"x", self.ranges[0] if self.ranges else (1, 48, 320, 3), 0)
obj.fmt = 1
elif cmd == 2:
self._fill(obj, b"logits", (1, 40, 97), 1)
elif cmd == 7:
obj.string = b'{"dims":[{}]}'
elif cmd == 13:
if self.ranges is None:
return -1
obj.shape_number, obj.n_dims, obj.fmt = len(self.ranges), 4, 1
for i, shape in enumerate(self.ranges):
obj.dyn_range[i][:4] = shape
elif cmd == 15:
self.output_queries += 1
width = dict(self.set_shapes)[ctx][2]
self._fill(obj, b"logits", (1, width // 8, 97), 1)
return 0
@staticmethod
def _fill(attr: Any, name: bytes, dims: tuple[int, ...], dtype: int) -> None:
attr.name, attr.n_dims, attr.type = name, len(dims), dtype
attr.dims[: len(dims)] = dims
def rknn_set_input_shapes(self, ctx: int, n: int, attrs: Any) -> int:
self.set_shapes.append((ctx, tuple(attrs[0].dims[: attrs[0].n_dims])))
return 0
def rknn_inputs_set(self, ctx: int, n: int, inputs: Any) -> int:
self.fed.append((ctx, inputs[0].type, inputs[0].fmt))
self.data.append(ctypes.string_at(inputs[0].buf, inputs[0].size))
self.buffers.append(inputs[0].buf)
return 0
def rknn_outputs_get(self, ctx: int, n: int, outputs: Any, extend: None) -> int:
assert outputs[0].is_prealloc and not outputs[0].want_float
np.frombuffer((ctypes.c_char * outputs[0].size).from_address(outputs[0].buf), np.uint8)[:] = 0
return 0
class TestRknnPool:
@pytest.fixture
def lib(self, mocker: MockerFixture) -> FakeRknn:
fake = FakeRknn()
mocker.patch("immich_ml.sessions.rknn.rknnpool.lib", fake, create=True)
return fake
@pytest.fixture
def dynamic_lib(self, mocker: MockerFixture) -> FakeRknn:
fake = FakeRknn(ranges=[(1, 48, 320, 3), (1, 48, 640, 3)])
mocker.patch("immich_ml.sessions.rknn.rknnpool.lib", fake, create=True)
return fake
def test_duplicates_one_context_per_thread(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 3)
assert pool.contexts == [1, 2, 3]
assert pool.inputs[0] == ("x", (1, 48, 320, 3))
assert pool.outputs[0] == ("logits", (1, 40, 97))
assert pool.custom_string == '{"dims":[{}]}'
assert not pool.dynamic
pool.release()
assert lib.rknn_destroy.call_args_list == [mock.call(3), mock.call(2), mock.call(1)]
def test_feeds_the_dtype_given_as_nhwc(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
image = np.arange(48 * 320 * 3, dtype=np.uint8).reshape(1, 48, 320, 3)
pool.run([image], "nhwc")
pool.run([image.transpose(0, 3, 1, 2).astype(np.float32)], "nchw")
assert lib.fed == [(1, 3, 1), (1, 0, 1)] # (context, rknn_tensor_type, rknn_tensor_format)
assert lib.data[0] == image.tobytes()
assert lib.data[1] == image.astype(np.float32).tobytes() # transposed back to channels last
def test_feeds_a_channels_first_view_of_channels_last_data_without_a_copy(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
image = np.zeros((1, 48, 320, 3), dtype=np.float32)
pool.run([image.transpose(0, 3, 1, 2)], "nchw")
assert lib.buffers == [image.ctypes.data]
def test_makes_every_input_contiguous(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
image = np.arange(48 * 640 * 3, dtype=np.uint8).reshape(1, 48, 640, 3)[:, :, ::2]
pool.run([image], "nhwc")
assert lib.data == [np.ascontiguousarray(image).tobytes()]
def test_returns_outputs_in_the_precision_computed(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
[output] = pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert output.dtype == np.float16
assert output.shape == (1, 40, 97)
lib.rknn_outputs_release.assert_called_once()
def test_a_failed_init_destroys_nothing(self, lib: FakeRknn, mocker: MockerFixture) -> None:
mocker.patch.object(lib, "rknn_init", return_value=-6)
with pytest.raises(RuntimeError, match="rknn_init failed"):
RknnPoolExecutor("model.rknn", 3)
lib.rknn_destroy.assert_not_called()
def test_returns_the_context_when_a_run_fails(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 2)
lib.rknn_run.return_value = -1
with pytest.raises(RuntimeError, match="rknn_run failed"):
pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert pool.idle.qsize() == 2
def test_runs_on_every_context_at_once(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 3)
barrier = threading.Barrier(3, timeout=5) # breaks unless all three are inside rknn_run together
lib.rknn_run.side_effect = lambda ctx, extend: barrier.wait() * 0
with ThreadPoolExecutor(3) as executor:
futures = [executor.submit(pool.run, [np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc") for _ in range(3)]
[future.result() for future in futures]
assert sorted(ctx for ctx, _, _ in lib.fed) == [1, 2, 3]
def test_a_waiting_run_is_not_overtaken_by_the_caller_that_just_ran(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
def run(ctx: int, extend: None) -> int:
time.sleep(0.01)
return 0
lib.rknn_run.side_effect = run
busy, waiting = np.zeros((1, 48, 320, 3), np.uint8), np.ones((1, 48, 320, 3), np.uint8)
caller = threading.Thread(target=lambda: [pool.run([busy], "nhwc") for _ in range(20)])
caller.start()
time.sleep(0.005)
pool.run([waiting], "nhwc")
caller.join(5)
assert [data[0] for data in lib.data].index(1) <= 2
def test_does_not_set_shapes_for_a_static_binary(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert lib.set_shapes == []
def test_routes_a_dynamic_binary_to_the_shape_fed(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
[wide] = pool.run([np.zeros((1, 3, 48, 640), dtype=np.float32)], "nchw")
[narrow] = pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert pool.dynamic
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (1, (1, 48, 320, 3))]
assert wide.shape == (1, 80, 97)
assert narrow.shape == (1, 40, 97)
def test_sets_the_shape_on_the_context_that_runs(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 2)
image = np.zeros((1, 48, 640, 3), dtype=np.uint8)
pool.run([image], "nhwc")
pool.run([image], "nhwc")
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (2, (1, 48, 640, 3))]
def test_sets_a_shape_again_only_when_it_changes(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
wide, narrow = np.zeros((1, 48, 640, 3), dtype=np.uint8), np.zeros((1, 48, 320, 3), dtype=np.uint8)
for image in (wide, wide, narrow, narrow, wide):
pool.run([image], "nhwc")
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (1, (1, 48, 320, 3)), (1, (1, 48, 640, 3))]
def test_queries_output_shapes_once_per_input_shape(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 2)
image = np.zeros((1, 48, 640, 3), dtype=np.uint8)
outputs = [pool.run([image], "nhwc")[0] for _ in range(4)]
assert dynamic_lib.output_queries == 1
assert all(output.shape == (1, 80, 97) for output in outputs)
def test_rejects_a_shape_the_binary_was_not_compiled_for(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
with pytest.raises(ValueError, match="compiled for"):
pool.run([np.zeros((1, 48, 480, 3), dtype=np.uint8)], "nhwc")
assert pool.idle.qsize() == 1
class TestCLIP:
embedding = np.random.rand(512).astype(np.float32)
cache_dir = Path("test_cache")
@@ -1856,6 +2104,14 @@ class TestCache:
assert len(model_cache._models) == 1
model.create.assert_called_once()
async def test_shares_an_instance_across_names_of_the_same_model(self) -> None:
model_cache = ModelCache()
bare = model_cache.get(InferenceEntry(OpenClipTextualEncoder, "ViT-B-32__openai", TextualOptions()))
prefixed = model_cache.get(
InferenceEntry(OpenClipTextualEncoder, "immich-app/ViT-B-32__openai", TextualOptions(language="en-US"))
)
assert bare is prefixed
async def test_separate_instances_per_graph_option(self) -> None:
# the real TextDetector, so that keying it on anything but its resolution fails this
model_cache = ModelCache()
+795 -610
View File
File diff suppressed because it is too large Load Diff
+115 -152
View File
@@ -1,29 +1,22 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
lockfile_version = 3
[[tools."github:extism/cli"]]
version = "1.6.3"
backend = "github:extism/cli"
specifiers = ["1.6.3"]
[tools."github:extism/cli"."platforms.linux-arm64"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
[tools."github:extism/cli"."platforms.linux-x64"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
[tools."github:extism/cli"."platforms.linux-x64-musl"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
[tools."github:extism/cli"."platforms.macos-arm64"]
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
@@ -42,27 +35,18 @@ url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
[[tools."github:extism/js-pdk"]]
version = "1.7.0"
backend = "github:extism/js-pdk"
specifiers = ["v1.7.0"]
[tools."github:extism/js-pdk"."platforms.linux-arm64"]
checksum = "sha256:025f4050b199d68413c159bde1187271ae270021a9f7171e7beb509922821f2a"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632503"
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
checksum = "sha256:025f4050b199d68413c159bde1187271ae270021a9f7171e7beb509922821f2a"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632503"
[tools."github:extism/js-pdk"."platforms.linux-x64"]
checksum = "sha256:63b72da2f5e88655522dc21477de549f238a2f40546a69ce4e0fce7e78654035"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-x86_64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632991"
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
checksum = "sha256:63b72da2f5e88655522dc21477de549f238a2f40546a69ce4e0fce7e78654035"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-x86_64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632991"
[tools."github:extism/js-pdk"."platforms.macos-arm64"]
checksum = "sha256:12c01c2bb2240a6a05a4f8babe680c793c397d84e6a1497a7ca1312e78a475c3"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-macos-v1.7.0.gz"
@@ -81,6 +65,7 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518636347"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
@@ -90,18 +75,33 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
@@ -114,61 +114,31 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
specifiers = ["version_124"]
[tools."github:webassembly/binaryen"."platforms.linux-arm64"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
[tools."github:webassembly/binaryen"."platforms.linux-x64"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
@@ -187,9 +157,10 @@ url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288
[[tools.java]]
version = "21.0.2"
backend = "core:java"
specifiers = ["21.0.2"]
[tools.java.options]
shorthand_vendor = "openjdk"
shorthand_vendor = "temurin"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
@@ -212,159 +183,151 @@ checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.21.0"
version = "24.22.1"
backend = "core:node"
specifiers = ["24.22.1"]
[tools.node."platforms.linux-arm64"]
checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz"
checksum = "sha256:33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3"
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz"
[tools.node."platforms.linux-x64"]
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
checksum = "sha256:eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c"
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"]
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
[tools.node."platforms.macos-arm64"]
checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz"
checksum = "sha256:89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7"
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz"
[tools.node."platforms.macos-x64"]
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
checksum = "sha256:d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035"
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz"
[tools.node."platforms.windows-x64"]
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
checksum = "sha256:d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5"
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip"
[[tools."npm:@openapitools/openapi-generator-cli"]]
version = "2.41.0"
version = "2.42.0"
aube = { path = ".mise/locks/npm-openapitools-openapi-generator-cli/2.42.0", digest = "sha256:ef9f5cf15f958d31297bb706ee83d1347633603f3079431938665c7bc4136856" }
backend = "npm:@openapitools/openapi-generator-cli"
specifiers = ["2.42.0"]
[[tools."npm:oazapfts"]]
version = "7.5.0"
aube = { path = ".mise/locks/npm-oazapfts/7.5.0", digest = "sha256:74214614b0338eaa2baee026d615afde514311c5c76e274f420f8de8b1f0e017" }
backend = "npm:oazapfts"
specifiers = ["7.5.0"]
[[tools.opentofu]]
version = "1.12.6"
version = "1.13.2"
backend = "aqua:opentofu/opentofu"
specifiers = ["1.13.2"]
[tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
checksum = "sha256:88e2d9e3f94f97b76ab2faf55ade87578f56aed19c5e45fa139d70e994366d1f"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742799"
[tools.opentofu."platforms.linux-x64"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
checksum = "sha256:ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742636"
[tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
checksum = "sha256:0d1cdc85f38992055b48e7fb161304a2ddfe2e5ee0ff68ffec9d0349c8bd1b59"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742637"
[tools.opentofu."platforms.macos-x64"]
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
checksum = "sha256:dc6e4eb1d3ed6550c2ef052596f933dbcd8c806957665ea83ae7537147cb3074"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742982"
[tools.opentofu."platforms.windows-x64"]
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
checksum = "sha256:e74fc2bda0c81ce696b6b7016a4d5c1f557638161239e4101e9ee7d8e0faba59"
checksum_source = "mise-versions"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
[[tools.pnpm]]
version = "11.27.1"
version = "12.15.0"
backend = "aqua:pnpm/pnpm"
specifiers = ["12.15.0"]
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:dbf8f84263d2bc696306d4219be68256d881d9af409345374d878676a7edcf0c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602580"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:7a7854e90bdb7d339430d9943ea482b34d7bde817cfc1c749d3c04ddd464142d"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602583"
checksum = "sha256:d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117"
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173352"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:6be1426540406747b7731564a46c5b30d3023ea6c19e4ce99f994c4ec122f3d2"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602576"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:cc66c1558a1a03b320ff5b4022f755386fa1de54db3f97ff624112015be1ccf1"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602579"
checksum = "sha256:55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598"
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173360"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:352f67ceaaa5aba294a53f483da93c376005b4c7e785fe5411e0ce720fe74741"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602575"
checksum = "sha256:6a41a2877773e97f19832067187cc6fc82e186d8a2cf91fec4b4c1a5fbd6ef27"
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173343"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:2d7b336884574aee8a765bee48591212e9caf4fcf985fb3279588c1fb73fb47f"
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-darwin-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173347"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:4b56d1184c3390b08146060fa4b414b8ceec9164aa41bf996e87a0f0bc135851"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602578"
checksum = "sha256:84cf69db1be19e95bb4e5bef329291a046f2821b390d87e00f5690809d8a2ed7"
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173351"
provenance = "github-attestations"
[[tools.terragrunt]]
version = "1.1.1"
version = "1.2.0"
backend = "aqua:gruntwork-io/terragrunt"
specifiers = ["1.2.0"]
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
checksum = "sha256:1e13b7f0646fa7962bc6c5bfba14a30d6a589325b82561e21b66be5981d86395"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437526"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
checksum = "sha256:23b5f2f9203d34919f7fd270a4b635b721b4903beca9762a1e4853b159bc9be8"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437263"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628"
checksum = "sha256:79790d841ce71fe94c689fb6f5034afd31ad53d5904937c7a10934cca9d9734b"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436516"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520"
checksum = "sha256:0b0e63e80509eb1ffb4f89b892bc6fbf7728f27f0a73974400f1bd82a80b8627"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436142"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421"
checksum = "sha256:c9a146b9aad456a289614023ad5778123d819af3cf9efd1c6130f196cfcb1999"
checksum_source = "mise-versions"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662438255"
+5 -5
View File
@@ -15,11 +15,11 @@ config_roots = [
]
[tools]
node = "24.21.0"
pnpm = "11.27.1"
terragrunt = "1.1.1"
opentofu = "1.12.6"
"npm:@openapitools/openapi-generator-cli" = "2.41.0"
node = "24.22.1"
pnpm = "12.15.0"
terragrunt = "1.2.0"
opentofu = "1.13.2"
"npm:@openapitools/openapi-generator-cli" = "2.42.0"
"npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
+1 -1
View File
@@ -1,6 +1,6 @@
# Immich Mobile Application - Flutter
The Immich mobile app is a Flutter-based solution leveraging the Isar Database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance.
The Immich mobile app is a Flutter-based solution leveraging the Drift (SQLite) database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance.
## Setup
-13
View File
@@ -1,13 +0,0 @@
cmake_minimum_required(VERSION 3.12)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
src/main/cpp/native_image.c
)
target_link_libraries(native_buffer jnigraphics)
-5
View File
@@ -79,11 +79,6 @@ android {
}
namespace 'app.alextran.immich'
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
flutter {
@@ -1,52 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <string.h>
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_allocate(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_NativeBuffer_free(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_realloc(
JNIEnv *env, jclass clazz, jlong address, jint size) {
void *ptr = realloc((void *) address, size);
return (jlong) ptr;
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_NativeBuffer_wrap(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_NativeBuffer_copy(
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
void *src = (*env)->GetDirectBufferAddress(env, buffer);
if (src != NULL) {
memcpy((void *) destAddress, (char *) src + offset, length);
}
}
/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}
jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}
@@ -1,173 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <stdint.h>
#include <android/bitmap.h>
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
#define TILE 32
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
enum {
ORIENTATION_FLIP_HORIZONTAL = 2,
ORIENTATION_ROTATE_180 = 3,
ORIENTATION_FLIP_VERTICAL = 4,
ORIENTATION_TRANSPOSE = 5,
ORIENTATION_ROTATE_90 = 6,
ORIENTATION_TRANSVERSE = 7,
ORIENTATION_ROTATE_270 = 8,
};
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
static int swaps_dims(int o) {
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
}
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
switch (o) {
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
default: *base = 0; *stepX = 1; *stepY = dw; break;
}
}
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
for (int ty = 0; ty < sh; ty += TILE) {
int yEnd = ty + TILE < sh ? ty + TILE : sh;
for (int tx = 0; tx < sw; tx += TILE) {
int xEnd = tx + TILE < sw ? tx + TILE : sw;
for (int sy = ty; sy < yEnd; sy++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
for (int sx = tx; sx < xEnd; sx++) {
dst[idx] = srcRow[sx];
idx += stepX;
}
}
}
}
}
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_rotate(
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
return 0;
}
int sw = (int) info.width;
int sh = (int) info.height;
int dw = swaps_dims(orientation) ? sh : sw;
int dh = swaps_dims(orientation) ? sw : sh;
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
int64_t base, stepX, stepY;
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {dw, dh, dw * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
for (int y = 0; y < h; y++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
uint32_t *dstRow = dst + (size_t) y * w;
for (int x = 0; x < w; x++) {
uint32_t px = srcRow[x];
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t a = ((px >> 30) & 0x3) * 85u;
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
}
}
}
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_convert1010102(
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
return 0;
}
int w = (int) info.width;
int h = (int) info.height;
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {w, h, w * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
object NativeBuffer {
init {
System.loadLibrary("native_buffer")
System.loadLibrary("native_core_ffi")
}
@JvmStatic
@@ -1,11 +1,11 @@
package app.alextran.immich
import android.graphics.Bitmap
import java.nio.ByteBuffer
object NativeImage {
init {
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
System.loadLibrary("native_buffer")
System.loadLibrary("native_core_ffi")
}
/**
@@ -26,4 +26,24 @@ object NativeImage {
*/
@JvmStatic
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
/**
* Like [convert1010102], but decodes the image of [length] bytes at [address], stopped by
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
* within [maxPixelSize].
*/
@JvmStatic
external fun decode(
address: Long,
length: Int,
targetWidth: Int,
targetHeight: Int,
maxPixelSize: Int,
cancelled: ByteBuffer,
outInfo: IntArray
): Long
/** Stops the [decode] given this one-byte direct buffer. */
@JvmStatic
external fun cancel(cancelled: ByteBuffer)
}
@@ -10,6 +10,7 @@ import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.NativeImage
import app.alextran.immich.core.HttpClientManager
import kotlinx.coroutines.*
import okhttp3.Cache
@@ -29,6 +30,8 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
// Match the upstream GPU texture bound without depending on a newer local image decoder.
private const val MAX_JXL_PIXEL_SIZE = 16384
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -56,10 +59,9 @@ private val RAW_MIME_TYPES = setOf(
"image/x-sony-sr2", "image/x-sony-srf", "image/x3f",
)
private fun isRawMime(contentType: String?): Boolean {
val mime = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return false
return mime in RAW_MIME_TYPES
}
private fun mimeType(contentType: String?) = contentType?.substringBefore(';')?.trim()?.lowercase()
private fun isRawMime(contentType: String?) = mimeType(contentType) in RAW_MIME_TYPES
class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
@@ -97,6 +99,42 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
return@fetch callback(CANCELLED)
}
// Neither Android nor Flutter can decode JPEG XL, so this applies to encoded requests too.
if (mimeType(contentType) == "image/jxl") {
decodeExecutor.execute {
val info = IntArray(3)
// The fetch is done, so cancelling now stops the decode instead.
val cancelled = ByteBuffer.allocateDirect(1)
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
val pointer = if (signal.isCanceled) 0L else NativeImage.decode(
buffer.pointer,
buffer.offset,
width?.toInt() ?: 0,
height?.toInt() ?: 0,
MAX_JXL_PIXEL_SIZE,
cancelled,
info
)
buffer.free()
requestMap.remove(requestId)
callback(
when {
pointer != 0L -> Result.success(
mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
)
signal.isCanceled -> CANCELLED
else -> Result.failure(IOException("Failed to decode JPEG XL image"))
}
)
}
return@fetch
}
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
// dedicated pool - the fetch callback threads are shared with video streaming. On any
@@ -272,7 +310,11 @@ private class CronetImageFetcher : ImageFetcher {
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
// Cronet's disk cache can still use a read buffer after onCanceled, so only cancel between reads.
signal.setOnCancelListener {
callback.requestCancel = true
if (!callback.receivingResponse) request.cancel()
}
request.start()
}
@@ -328,12 +370,16 @@ private class CronetImageFetcher : ImageFetcher {
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var error: Exception? = null
@Volatile var receivingResponse = false
@Volatile var requestCancel = false
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
request.followRedirect()
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
receivingResponse = true
if (requestCancel) return request.cancel()
if (info.httpStatusCode !in 200..299) {
error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
return request.cancel()
@@ -361,6 +407,7 @@ private class CronetImageFetcher : ImageFetcher {
info: UrlResponseInfo,
byteBuffer: ByteBuffer
) {
if (requestCancel) return request.cancel()
try {
// Always pass a fresh wrap so byteBuffer.position() represents only the
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
@@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:native_core/native_core.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
test('native core loads on device', () {
expect(version(), isNotEmpty);
});
}
+39
View File
@@ -0,0 +1,39 @@
import Foundation
enum NativeCore {
private static let library = dlopen("@rpath/native_core_ffi.framework/native_core_ffi", RTLD_NOW)
private static func symbol<T>(_ name: String, as type: T.Type) -> T? {
guard let library, let symbol = dlsym(library, name) else { return nil }
return unsafeBitCast(symbol, to: type)
}
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self)
private static let coreDecodeImage = symbol("native_core_decode_image", as: NativeCoreDecodeImageFn.self)
private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self)
static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
guard let coreThumbhash else { return nil }
var width: Int32 = 0
var height: Int32 = 0
return hash.withUnsafeBytes { bytes in
guard let pointer = coreThumbhash(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, &width, &height)
else { return nil }
return (Int(width), Int(height), UnsafeMutableRawPointer(pointer))
}
}
static func decodeImage(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
guard let coreDecodeImage else { return nil }
var info = NativeCoreImageInfo()
return data.withUnsafeBytes { bytes in
guard let pointer = coreDecodeImage(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
else { return nil }
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
}
}
static func cancel(_ flag: UnsafeMutablePointer<Bool>) {
coreCancel?(flag)
}
}
@@ -9,4 +9,6 @@ enum ImageProcessing {
return q
}()
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
// Impeller shrinks each side to the GPU texture limit on its own, which squishes long images.
static let maxPixelSize = 16384
}
@@ -20,7 +20,6 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
private static let maxPixelSize: CGFloat = 16384
private static let registry = RequestRegistry<ImageRequest>()
@@ -111,7 +110,7 @@ class LocalImageApiImpl: LocalImageApi {
let isOriginal = !(width > 0 && height > 0)
let targetSize = isOriginal
? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
? CGSize(width: ImageProcessing.maxPixelSize, height: ImageProcessing.maxPixelSize)
: CGSize(width: Double(width), height: Double(height))
let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
var image: UIImage?
@@ -136,7 +135,7 @@ class LocalImageApiImpl: LocalImageApi {
}
// .fast can return larger than the target, so retry with .exact to guarantee the bound.
if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
if max(cgImage.width, cgImage.height) > ImageProcessing.maxPixelSize {
let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
exactOptions.resizeMode = .exact
image = nil
@@ -7,15 +7,23 @@ import Photos
final class RemoteImageRequest: ImageRequest {
var task: URLSessionDataTask?
let id: Int64
/// Lets cancellation stop a native decode partway through.
let decodeCancelled = UnsafeMutablePointer<Bool>.allocate(capacity: 1)
init(id: Int64, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id
super.init(completion: completion)
decodeCancelled.initialize(to: false)
}
deinit {
decodeCancelled.deallocate()
}
override func cancel() {
super.cancel()
task?.cancel()
NativeCore.cancel(decodeCancelled)
}
}
@@ -65,6 +73,38 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
// Flutter can't decode JPEG XL and older iOS versions can't either, so this applies to encoded requests too.
if response?.mimeType == "image/jxl" {
ImageProcessing.queue.addOperation {
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
guard let (width, height, pointer) = NativeCore.decodeImage(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
registry.remove(requestId: request.id)
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode JPEG XL image", details: nil)))
}
if request.isCancelled {
free(pointer)
return request.completion(ImageProcessing.cancelledResult)
}
registry.remove(requestId: request.id)
request.completion(
.success([
"pointer": Int64(Int(bitPattern: pointer)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4),
]))
}
return
}
if encoded {
let length = data.count
let pointer = malloc(length)!
@@ -1 +1,7 @@
#import "GeneratedPluginRegistrant.h"
#include "../../../native/crates/native_core_ffi/include/native_core.h"
// The function pointer type comes from the declaration, so nothing is retyped here.
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
typedef __typeof__(&native_core_decode_image) NativeCoreDecodeImageFn;
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
+1 -1
View File
@@ -122,7 +122,7 @@ class Drift extends $Drift {
@override
Future<void> close() async {
await super.close();
_updatePool?.close();
await _updatePool?.close();
}
Future<void> reset() async {
@@ -75,6 +75,7 @@ abstract class AppConfig with _$AppConfig {
.timelineTilesPerRow => timeline.tilesPerRow,
.timelineGroupAssetsBy => timeline.groupAssetsBy,
.timelineStorageIndicator => timeline.storageIndicator,
.timelineSmoothScrubbing => timeline.smoothScrubbing,
.mapShowFavoriteOnly => map.favoritesOnly,
.mapRelativeDate => map.relativeDays,
.mapCustomFrom => map.customFrom,
@@ -131,6 +132,7 @@ abstract class AppConfig with _$AppConfig {
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
.timelineSmoothScrubbing => copyWith(timeline: timeline.copyWith(smoothScrubbing: value as bool)),
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
@@ -10,5 +10,6 @@ abstract class TimelineConfig with _$TimelineConfig {
@Default(4) int tilesPerRow,
@Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
@Default(true) bool storageIndicator,
@Default(false) bool smoothScrubbing,
}) = _TimelineConfig;
}
@@ -48,6 +48,7 @@ enum SettingsKey<T> {
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
timelineSmoothScrubbing<bool>(),
// Log
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
@@ -60,7 +60,6 @@ class AssetService {
}
final stack = await _remoteRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item
return [asset, ...stack];
}
+77 -5
View File
@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:http/http.dart' show ClientException;
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' show ApiException;
/// Service responsible for handling application logging.
///
@@ -14,8 +18,15 @@ import 'package:logging/logging.dart';
/// writes them to a persistent [LogRepository], and manages log levels via
/// [SettingsRepository].
class LogService {
static final _schemeAndHost = RegExp(r'''https?://[^\s/?#'"<>(),]*''');
static final _ipAddress = RegExp(
r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|'
r'\[?\b(?:(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:)+(?::[0-9a-f]{1,4})+)(?:%\w+)?\]?',
);
final LogRepository _logRepository;
final SettingsRepository _settingsRepository;
final StoreService _storeService;
final List<LogMessage> _msgBuffer = [];
@@ -39,11 +50,13 @@ class LogService {
static Future<LogService> init({
required LogRepository logRepository,
required SettingsRepository settingsRepository,
required StoreService storeService,
bool shouldBuffer = true,
}) async {
_instance ??= await create(
logRepository: logRepository,
settingsRepository: settingsRepository,
storeService: storeService,
shouldBuffer: shouldBuffer,
);
return _instance!;
@@ -52,16 +65,17 @@ class LogService {
static Future<LogService> create({
required LogRepository logRepository,
required SettingsRepository settingsRepository,
required StoreService storeService,
bool shouldBuffer = true,
}) async {
final instance = LogService._(logRepository, settingsRepository, shouldBuffer);
final instance = LogService._(logRepository, settingsRepository, storeService, shouldBuffer);
await logRepository.truncate(limit: kLogTruncateLimit);
final level = instance._settingsRepository.appConfig.logLevel;
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
return instance;
}
LogService._(this._logRepository, this._settingsRepository, this._shouldBuffer) {
LogService._(this._logRepository, this._settingsRepository, this._storeService, this._shouldBuffer) {
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
}
@@ -74,12 +88,12 @@ class LogService {
);
final record = LogMessage(
message: r.message,
message: _redact(r.message),
level: r.level.toLogLevel(),
createdAt: r.time,
logger: r.loggerName,
error: r.error?.toString(),
stack: r.stackTrace?.toString(),
error: r.error == null ? null : _redactError(r.error!),
stack: r.stackTrace == null ? null : _redact(r.stackTrace.toString()),
);
if (_shouldBuffer) {
@@ -100,6 +114,64 @@ class LogService {
return [..._msgBuffer.reversed, ...logsFromDb];
}
String _redact(String text) {
var redacted = text.replaceAllMapped(_schemeAndHost, (match) => _urlLabel(Uri.tryParse(match[0]!)));
final accessToken = _storeService.tryGet(StoreKey.accessToken);
if (accessToken != null) {
redacted = redacted.replaceAll(accessToken, '[ACCESS TOKEN]');
}
// as a header map prints it, so a short value like "1" can't match other text
for (final header in _settingsRepository.appConfig.network.customHeaders.entries) {
redacted = redacted.replaceAll(
RegExp('${RegExp.escape(header.key)}:\\s+${RegExp.escape(header.value)}'),
'${header.key}: [HEADER VALUE]',
);
}
return redacted;
}
String _redactError(Object error) {
final redacted = _redact(error.toString());
final httpError = error is ApiException ? error.innerException : error;
if (httpError is! ClientException) {
return redacted;
}
// ok_http also prints the host and its ip on their own: "Failed to connect to host/203.0.113.9:443"
final url = httpError.uri;
final withoutHost = url == null || url.host.isEmpty
? redacted
: redacted.replaceAll(RegExp('\\b${RegExp.escape(url.host)}\\b'), _urlLabel(url));
return withoutHost.replaceAll(_ipAddress, '[IP ADDRESS]');
}
String _urlLabel(Uri? url) {
if (url == null) {
return '[OTHER URL]';
}
// a server url that is also a switching entry gets the entry's label
final network = _settingsRepository.appConfig.network;
for (var i = 0; i < network.externalEndpointList.length; i++) {
if (_isSameAddress(url, network.externalEndpointList[i])) {
return '[EXTERNAL ENDPOINT ${i + 1}]';
}
}
if (_isSameAddress(url, network.localEndpoint)) {
return '[LOCAL ENDPOINT]';
}
if (_isSameAddress(url, _storeService.tryGet(StoreKey.serverUrl)) ||
_isSameAddress(url, _storeService.tryGet(StoreKey.serverEndpoint))) {
return '[SERVER URL]';
}
return '[OTHER URL]';
}
bool _isSameAddress(Uri url, String? savedUrl) {
final saved = savedUrl == null ? null : Uri.tryParse(savedUrl);
return saved != null && saved.scheme == url.scheme && saved.host == url.host && saved.port == url.port;
}
Future<void> clearLogs() async {
_flushTimer?.cancel();
_flushTimer = null;
@@ -74,8 +74,8 @@ class TimelineFactory {
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
TimelineService person(List<String> userIds, String personId) =>
TimelineService(_timelineRepository.person(userIds, personId, groupBy));
TimelineService person(String userId, String personId) =>
TimelineService(_timelineRepository.person(userId, personId, groupBy));
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
@@ -79,8 +79,7 @@ abstract class ImageRequest {
return (codec, descriptor);
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
Future<ui.FrameInfo?> _firstFrame((ui.Codec, ui.ImageDescriptor)? result) async {
if (result == null) {
return null;
}
@@ -116,7 +115,12 @@ abstract class ImageRequest {
return ((width * scale).ceil(), (height * scale).ceil());
}
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromDecodedPlatformImage(
int address,
int width,
int height,
int rowBytes,
) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
@@ -152,14 +156,6 @@ abstract class ImageRequest {
return null;
}
final frame = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
if (_isCancelled) {
frame.image.dispose();
return null;
}
return frame;
return (codec, descriptor);
}
}
@@ -28,7 +28,9 @@ class LocalImageRequest extends ImageRequest {
return null;
}
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
final frame = await _firstFrame(
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@@ -21,22 +21,7 @@ class RemoteImageRequest extends ImageRequest {
width: decodeSize?.width.ceil(),
height: decodeSize?.height.ceil(),
);
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
final frame = switch (info) {
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(
pointer,
length,
decodeSize: decodeSize,
),
{
'pointer': final int pointer,
'width': final int width,
'height': final int height,
'rowBytes': final int rowBytes,
} =>
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
_ => null,
};
final frame = await _firstFrame(await _codecFromResponse(info, decodeSize: decodeSize));
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@@ -53,13 +38,28 @@ class RemoteImageRequest extends ImageRequest {
width: null,
height: null,
);
if (info == null) {
return null;
final result = await _codecFromResponse(info);
return result?.$1;
}
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
return codec;
}
// Android falls back to encoded data if native decoding fails, and JPEG XL arrives decoded even when the encoded image
// is requested, so check for both shapes of the response.
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromResponse(Map<String, int>? info, {ui.Size? decodeSize}) =>
switch (info) {
{'pointer': final int pointer, 'length': final int length} => _codecFromEncodedPlatformImage(
pointer,
length,
decodeSize: decodeSize,
),
{
'pointer': final int pointer,
'width': final int width,
'height': final int height,
'rowBytes': final int rowBytes,
} =>
_codecFromDecodedPlatformImage(pointer, width, height, rowBytes),
_ => Future.value(),
};
@override
Future<void> _onCancelled() {
@@ -12,7 +12,9 @@ class ThumbhashImageRequest extends ImageRequest {
}
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
final frame = await _firstFrame(
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@@ -41,6 +41,23 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
return _assetSelectable(id).watchSingleOrNull();
}
Stream<Set<String>> watchDeletedAssetIds(Iterable<String> ids, AssetVisibility visibility) {
final idList = ids.toList(growable: false);
if (idList.isEmpty) {
return Stream.value(const <String>{});
}
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id])
..where(
_db.remoteAssetEntity.id.isIn(idList) &
(_db.remoteAssetEntity.deletedAt.isNotNull() |
_db.remoteAssetEntity.visibility.equalsValue(visibility).not()),
);
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).watch().map((rows) => rows.toSet());
}
Future<RemoteAsset?> get(String id) {
return _assetSelectable(id).getSingleOrNull();
}
@@ -59,7 +76,7 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
final query = _db.remoteAssetEntity.select()
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
..orderBy([(row) => OrderingTerm.asc(row.createdAt)]);
return query.map((row) => row.toDto()).get();
}
@@ -923,7 +923,7 @@ class SyncStreamRepository extends DatabaseAccessor<Drift> with $SyncStreamRepos
try {
await _db.batch((batch) {
for (final assetOcr in data) {
batch.deleteWhere(_db.assetOcrEntity, (row) => row.id.equals(assetOcr.id));
batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetOcr.assetId));
}
});
} catch (error, stack) {
@@ -366,10 +366,10 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
origin: TimelineOrigin.place,
);
TimelineQuery person(List<String> userIds, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userIds, personId, groupBy: groupBy),
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
assetSource: (offset, count) =>
_getPersonBucketAssets(userIds, personId, groupBy: groupBy, offset: offset, count: count),
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.person,
);
@@ -430,7 +430,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
Expression<bool> _personAssetFilter(List<String> userIds, String personId) {
Expression<bool> _personAssetFilter(String userId, String personId) {
final faceAssetIds = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
..where(
@@ -438,22 +438,23 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
final partnerIds = _db.partnerEntity.selectOnly()
..addColumns([_db.partnerEntity.sharedById])
..where(_db.partnerEntity.sharedWithId.equals(userId));
final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]);
return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) &
(_db.remoteAssetEntity.ownerId.isIn(userIds) | _db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
(_db.remoteAssetEntity.ownerId.equals(userId) |
_db.remoteAssetEntity.ownerId.isInQuery(partnerIds) |
_db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline);
}
Stream<List<Bucket>> _watchPersonBucket(
List<String> userIds,
String personId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id.count()])
..where(_personAssetFilter(userIds, personId));
..where(_personAssetFilter(userId, personId));
return query.map((row) {
final count = row.read(_db.remoteAssetEntity.id.count())!;
@@ -466,7 +467,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..where(_personAssetFilter(userIds, personId))
..where(_personAssetFilter(userId, personId))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
@@ -478,14 +479,14 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
}
Future<List<BaseAsset>> _getPersonBucketAssets(
List<String> userIds,
String userId,
String personId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final query = _db.remoteAssetEntity.select()
..where((_) => _personAssetFilter(userIds, personId))
..where((_) => _personAssetFilter(userId, personId))
..orderBy(_assetDateOrder(groupBy))
..limit(count, offset: offset);
+1
View File
@@ -74,6 +74,7 @@ void main() async {
),
);
} catch (error, stack) {
Logger('main').severe('Failed to start', error, stack);
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
}
}
+18 -21
View File
@@ -22,6 +22,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/fallback_localizations.dart';
@@ -78,9 +79,12 @@ class BootstrapErrorWidget extends StatelessWidget {
),
),
const Divider(height: 1),
const SafeArea(
SafeArea(
top: false,
child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: _BottomPanel(startupError: '$error\n\n$stack'),
),
),
],
),
@@ -93,7 +97,9 @@ class BootstrapErrorWidget extends StatelessWidget {
}
class _BottomPanel extends StatefulWidget {
const _BottomPanel();
final String startupError;
const _BottomPanel({required this.startupError});
@override
State<_BottomPanel> createState() => _BottomPanelState();
@@ -157,8 +163,8 @@ class _BottomPanelState extends State<_BottomPanel> {
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
Wrap(
alignment: WrapAlignment.center,
children: [
_ActionLink(
icon: Icons.chat_bubble_outline,
@@ -173,6 +179,11 @@ class _BottomPanelState extends State<_BottomPanel> {
mode: LaunchMode.externalApplication,
),
),
_ActionLink(
icon: Icons.share_outlined,
label: context.t.share_logs,
onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError),
),
if (!_cleared)
_ActionLink(
icon: Icons.delete_outline,
@@ -288,21 +299,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
@override
void initState() {
super.initState();
unawaited(
ref
.read(authProvider.notifier)
.setOpenApiServiceEndpoint()
.then(logConnectionInfo)
.whenComplete(() => resumeSession()),
);
}
void logConnectionInfo(String? endpoint) {
if (endpoint == null) {
return;
}
log.info("Resuming session at $endpoint");
unawaited(ref.read(authProvider.notifier).setOpenApiServiceEndpoint().whenComplete(() => resumeSession()));
}
Future<void> resumeSession() async {
@@ -356,7 +353,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
},
onError: (exception) {
log.severe('Failed to update auth info with access token: $accessToken');
log.severe('Failed to update auth info', exception);
if (!mounted) {
return;
}
@@ -121,14 +121,14 @@ class DeleteAction extends AssetActionBuilder {
required bool isLocalOnly,
}) async {
final assetService = ref.read(assetServiceProvider);
final message = context.t.trash_action_prompt(count: remoteIds.length);
if (localIds.isNotEmpty) {
await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (!context.mounted) {
final count = await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (count <= 0) {
return null;
}
}
final message = context.t.trash_action_prompt(count: remoteIds.length);
await assetService.trash(remoteIds);
return message;
}
+53 -67
View File
@@ -417,21 +417,57 @@ final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>(
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith);
});
class _QuickAccessItem {
final Widget leading;
final String title;
final VoidCallback onTap;
final EdgeInsetsGeometry? contentPadding;
const _QuickAccessItem({required this.leading, required this.title, required this.onTap, this.contentPadding});
}
class _QuickAccessButtonList extends ConsumerWidget {
const _QuickAccessButtonList();
static const _tileRadius = 20.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider);
final partners = partnerSharedWithAsync.valueOrNull ?? [];
final items = [
_QuickAccessItem(
leading: const Icon(Icons.folder_outlined, size: 26),
title: context.t.folders,
onTap: () => context.pushRoute(FolderRoute()),
),
_QuickAccessItem(
leading: const Icon(Icons.lock_outline_rounded, size: 26),
title: context.t.locked_folder,
onTap: () => context.pushRoute(const LockedFolderRoute()),
),
_QuickAccessItem(
leading: const Icon(Icons.group_outlined, size: 26),
title: context.t.partners,
onTap: () => context.pushRoute(const PartnerRoute()),
),
for (final partner in partners)
_QuickAccessItem(
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: context.t.partner_list_user_photos(user: partner.name),
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
),
];
return SliverPadding(
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
sliver: SliverToBoxAdapter(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)),
borderRadius: const BorderRadius.all(Radius.circular(_tileRadius)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
@@ -442,83 +478,33 @@ class _QuickAccessButtonList extends ConsumerWidget {
end: Alignment.bottomCenter,
),
),
child: ListView(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
children: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
),
),
leading: const Icon(Icons.folder_outlined, size: 26),
title: Text(
context.t.folders,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(FolderRoute()),
),
ListTile(
leading: const Icon(Icons.lock_outline_rounded, size: 26),
title: Text(
context.t.locked_folder,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const LockedFolderRoute()),
),
ListTile(
leading: const Icon(Icons.group_outlined, size: 26),
title: Text(
context.t.partners,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const PartnerRoute()),
),
_PartnerList(partners: partners.toList()),
],
),
),
),
);
}
}
class _PartnerList extends StatelessWidget {
const _PartnerList({required this.partners});
final List<Partner> partners;
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: partners.length,
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final partner = partners[index];
final isLastItem = index == partners.length - 1;
final item = items[index];
final isFirst = index == 0;
final isLast = index == items.length - 1;
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
bottomRight: Radius.circular(isLastItem ? 20 : 0),
topLeft: Radius.circular(isFirst ? _tileRadius : 0),
topRight: Radius.circular(isFirst ? _tileRadius : 0),
bottomLeft: Radius.circular(isLast ? _tileRadius : 0),
bottomRight: Radius.circular(isLast ? _tileRadius : 0),
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: Text(
context.t.partner_list_user_photos(user: partner.name),
style: const TextStyle(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
contentPadding: item.contentPadding,
leading: item.leading,
title: Text(item.title, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
onTap: item.onTap,
);
},
),
),
),
);
}
}
@@ -82,8 +82,7 @@ class _PersonPageState extends ConsumerState<PersonPage> {
throw Exception('User must be logged in to view person timeline');
}
final users = ref.watch(timelineUsersProvider).valueOrNull ?? [user.id];
final timelineService = ref.watch(timelineFactoryProvider).person(users, _person.id);
final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
@@ -3,7 +3,9 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
@@ -32,16 +34,21 @@ class SearchState {
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider), ref.watch(driftProvider).remoteAssetRepository),
);
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService;
final RemoteAssetRepository _remoteAssetRepository;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
StreamSubscription<Set<String>>? _deletedIdsSubscription;
List<BaseAsset> _results = const [];
int? _nextPage = 1;
Stream<int> get assetCount => _assetCountController.stream;
PaginatedSearchNotifier(this._searchService, this._remoteAssetRepository) : super(const SearchState());
Stream<int> get assetCount => _assetCountController.stream.distinct();
Future<void> search(SearchFilter filter) async {
if (state.nextPage == null || state.isLoading) {
@@ -52,24 +59,47 @@ class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final result = await _searchService.search(filter, state.nextPage!);
if (!mounted) {
return;
}
if (result == null) {
state = SearchState(assets: state.assets, nextPage: state.nextPage);
return;
}
final assets = [...state.assets, ...result.assets];
state = SearchState(assets: assets, nextPage: result.nextPage);
_assetCountController.add(assets.length);
_results = [..._results, ...result.assets];
_nextPage = result.nextPage;
_watchDeletedIds(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline);
}
void clear() {
state = const SearchState();
_assetCountController.add(0);
unawaited(_deletedIdsSubscription?.cancel());
_deletedIdsSubscription = null;
_results = const [];
_nextPage = 1;
_emit(const {});
}
void _watchDeletedIds(AssetVisibility visibility) {
unawaited(_deletedIdsSubscription?.cancel());
final ids = _results.whereType<RemoteAsset>().map((asset) => asset.id);
_deletedIdsSubscription = _remoteAssetRepository.watchDeletedAssetIds(ids, visibility).listen(_emit);
}
void _emit(Set<String> deletedIds) {
final visible = deletedIds.isEmpty
? _results
: _results.where((asset) => asset is! RemoteAsset || !deletedIds.contains(asset.id)).toList(growable: false);
state = SearchState(assets: visible, nextPage: _nextPage);
_assetCountController.add(visible.length);
}
@override
void dispose() {
unawaited(_deletedIdsSubscription?.cancel());
unawaited(_assetCountController.close());
super.dispose();
}
@@ -42,12 +42,16 @@ class SearchPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
final textSearchType = useState<TextSearchType>(
serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename,
);
final searchHintText = useState<String>(
serverFeatures.smartSearch ? context.t.sunrise_on_the_beach : context.t.file_name_or_extension,
);
// Stays null until the user picks a type, so the default follows the server features once they are loaded
final selectedTextSearchType = useState<TextSearchType?>(null);
final textSearchType =
selectedTextSearchType.value ?? (serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename);
final searchHintText = switch (textSearchType) {
TextSearchType.context => context.t.sunrise_on_the_beach,
TextSearchType.filename => context.t.file_name_or_extension,
TextSearchType.description => context.t.search_by_description_example,
TextSearchType.ocr => context.t.search_by_ocr_example,
};
final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>(
SearchFilter(
@@ -491,14 +495,14 @@ class SearchPage extends HookConsumerWidget {
);
}
void handleTextSubmitted(String value) => search(switch (textSearchType.value) {
void handleTextSubmitted(String value) => search(switch (textSearchType) {
TextSearchType.context => filter.value.copyWith(filename: '', context: value, description: '', ocr: ''),
TextSearchType.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''),
TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''),
TextSearchType.ocr => filter.value.copyWith(filename: '', context: '', description: '', ocr: value),
});
IconData getSearchPrefixIcon() => switch (textSearchType.value) {
IconData getSearchPrefixIcon() => switch (textSearchType) {
TextSearchType.context => Icons.image_search_rounded,
TextSearchType.filename => Icons.abc_rounded,
TextSearchType.description => Icons.text_snippet_outlined,
@@ -543,15 +547,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_context,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
color: textSearchType == TextSearchType.context ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.context,
selected: textSearchType == TextSearchType.context,
),
onPressed: () {
textSearchType.value = TextSearchType.context;
searchHintText.value = context.t.sunrise_on_the_beach;
selectedTextSearchType.value = TextSearchType.context;
},
),
),
@@ -562,15 +565,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_filter_filename,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null,
color: textSearchType == TextSearchType.filename ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.filename,
selected: textSearchType == TextSearchType.filename,
),
onPressed: () {
textSearchType.value = TextSearchType.filename;
searchHintText.value = context.t.file_name_or_extension;
selectedTextSearchType.value = TextSearchType.filename;
},
),
MenuItemButton(
@@ -580,15 +582,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_description,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null,
color: textSearchType == TextSearchType.description ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.description,
selected: textSearchType == TextSearchType.description,
),
onPressed: () {
textSearchType.value = TextSearchType.description;
searchHintText.value = context.t.search_by_description_example;
selectedTextSearchType.value = TextSearchType.description;
},
),
FeatureCheck(
@@ -600,15 +601,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_ocr,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null,
color: textSearchType == TextSearchType.ocr ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.ocr,
selected: textSearchType == TextSearchType.ocr,
),
onPressed: () {
textSearchType.value = TextSearchType.ocr;
searchHintText.value = context.t.search_by_ocr_example;
selectedTextSearchType.value = TextSearchType.ocr;
},
),
),
@@ -631,7 +631,7 @@ class SearchPage extends HookConsumerWidget {
),
),
child: SearchField(
hintText: searchHintText.value,
hintText: searchHintText,
key: const Key('search_text_field'),
controller: textSearchController,
contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
@@ -8,6 +8,7 @@ import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/camera.utils.dart';
const _kSeparator = ' • ';
@@ -114,12 +115,7 @@ class TechnicalDetails extends ConsumerWidget {
if (exifInfo == null) {
return null;
}
return switch ((exifInfo.make, exifInfo.model)) {
(null, null) => null,
(final String make, null) => make,
(null, final String model) => model,
(final String make, final String model) => '$make $model',
};
return getCameraName(exifInfo.make, exifInfo.model);
}
static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
@@ -328,6 +328,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
final isBottomWidgetVisible =
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
final smoothScrubbing = ref.watch(appConfigProvider.select((config) => config.timeline.smoothScrubbing));
final snapToMonth = widget.snapToMonth && !smoothScrubbing;
return PopScope(
canPop: !isMultiSelectEnabled,
@@ -409,7 +411,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
snapToMonth: widget.snapToMonth,
snapToMonth: snapToMonth,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
@@ -84,8 +84,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
// Needs to be logged in
if (isAuthenticated) {
// switch endpoint if needed
final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
_log.info("Using server URL: $endpoint");
await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
await _ref.read(serverInfoProvider.notifier).getServerVersion();
}
@@ -6,7 +6,6 @@ import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
@@ -21,7 +20,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router;
final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
@@ -74,8 +72,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
onSuccess: (fileId, _) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
},
onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
onError: (fileId, _) {
_updateStatus(fileId, UploadStatus.failed);
},
);
@@ -221,8 +221,6 @@ class BackupNotifier extends StateNotifier<BackupState> {
}
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
_logger.severe("Upload failed for $localAssetId: $errorMessage");
final currentItem = state.uploadItems[localAssetId];
if (currentItem != null) {
state = state.copyWith(
@@ -88,7 +88,7 @@ class UploadRepository {
} on RequestAbortedException {
rethrow;
} on ClientException catch (error) {
logger.warning("Upload $logContext failed before a response, resending once: $error");
logger.warning("Upload $logContext failed before a response, resending once", error);
response = await client.send(buildRequest());
}
@@ -124,8 +124,8 @@ class UploadRepository {
logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled();
} catch (error, stackTrace) {
logger.warning("Error uploading $logContext: $error: $stackTrace");
return UploadResult.error(errorMessage: error.toString());
logger.warning("Error uploading $logContext", error, stackTrace);
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: error.toString(), error: error);
}
}
}
@@ -166,6 +166,7 @@ class UploadResult {
final String? remoteAssetId;
final String? errorMessage;
final int? statusCode;
final Object? error;
const UploadResult({
required this.isSuccess,
@@ -173,6 +174,7 @@ class UploadResult {
this.remoteAssetId,
this.errorMessage,
this.statusCode,
this.error,
});
factory UploadResult.success({required String remoteAssetId}) {
+5 -2
View File
@@ -162,6 +162,7 @@ class AuthService {
final localEndpoint = _authRepository.getLocalEndpoint();
if (localEndpoint != null) {
await _apiService.resolveAndSetEndpoint(localEndpoint);
_log.info("Using local endpoint");
return localEndpoint;
}
} catch (error, stackTrace) {
@@ -181,9 +182,11 @@ class AuthService {
return null;
}
for (final endpoint in endpointList) {
for (var i = 0; i < endpointList.length; i++) {
try {
return await _apiService.resolveAndSetEndpoint(endpoint.url);
final url = await _apiService.resolveAndSetEndpoint(endpointList[i].url);
_log.info("Using external endpoint ${i + 1}");
return url;
} on ApiException catch (error) {
_log.severe("Cannot resolve endpoint", error);
continue;

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