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
67 changed files with 1317 additions and 498 deletions
+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!
+2
View File
@@ -2263,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",
+20 -20
View File
@@ -1482,7 +1482,7 @@ wheels = [
[[package]]
name = "onnxruntime"
version = "1.30.0"
version = "1.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
@@ -1491,25 +1491,25 @@ dependencies = [
{ name = "protobuf" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" },
{ url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" },
{ url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" },
{ url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" },
{ url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" },
{ url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" },
{ url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" },
{ url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" },
{ url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" },
{ url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" },
{ url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" },
{ url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" },
{ url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" },
{ url = "https://files.pythonhosted.org/packages/b3/bd/2ac094311163b803e3626c3937461d6900934bd56cca7601f6150ff860c3/onnxruntime-1.31.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:aaab9b3af536b06ca27ab5e35e3d429c97457ce76cf298af103f687e8b9975c0", size = 20882054, upload-time = "2026-10-09T04:18:18.811Z" },
{ url = "https://files.pythonhosted.org/packages/53/1a/561b43ca1536d9e81d1785bb8a1a260a9e314ef6d04976ba0411c652bda1/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:35758d7606d578ec5b9d65f6e8a1f488013194c3f6097038a3223cb26d35ef9a", size = 21420804, upload-time = "2026-10-09T04:18:21.729Z" },
{ url = "https://files.pythonhosted.org/packages/6c/44/1e9e762b95b7da0a8424913a1ed7c38cdaf88624a3c41ddba24ebac88bc9/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5e129d6c56abd53e659cb70f00a108d6824086470ff99c2e47a82e5786563db3", size = 23760984, upload-time = "2026-10-09T04:18:24.61Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/b12cea136ccd7b03d924f46b8393faf7ceac21115c0c50e729faa248cf23/onnxruntime-1.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:09d56445c1753e66e0912de69d3f0184016ad9a191dcd6925bf5dd570d2bfbe5", size = 14888841, upload-time = "2026-10-09T04:18:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/ad/37bbc51dcb5cd105c5b2fe98f122b23e90171c2719516964edc65bb1d4cc/onnxruntime-1.31.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c54a0eb7b2b4eef3eb9dcfaf82f5ce880db07288dc309574f6657e9da5cc754", size = 14740604, upload-time = "2026-10-09T04:18:30.399Z" },
{ url = "https://files.pythonhosted.org/packages/e0/2b/117f94d73a3bac4276c285c47e384e1b3ea67b191aa4c7592df9d3f4a136/onnxruntime-1.31.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0ba02a44acb6203040354d9a1f160e3f37a43feac7bb05caa3e0ea545efed505", size = 20881803, upload-time = "2026-10-09T04:18:33.62Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d0/3677fe93ec0fa3c637744aa4c3ae6ef89a93ee229cd3c5157820f267c7bd/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad663106f6eeff3d454f24a786450459d07f30e74863851104fc1b8b3f368127", size = 21420629, upload-time = "2026-10-09T04:18:36.731Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ac/67ebbaab4b3083f2a6b27ee6c4aa400c7f8d6c72b5499aac7e4cd6ba74f5/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:37fd78cee5160c7a43a1730ccb3682ffd880af9c9e80385d625c0c2f8b125809", size = 23760708, upload-time = "2026-10-09T04:18:40.883Z" },
{ url = "https://files.pythonhosted.org/packages/c4/86/05ed2056f43b27aaf12ebc592ebd9037a26bed315958cf882f43425fd469/onnxruntime-1.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:73e0165d58ece068c2a8a1c477c90b38e5a8adbbd399fdfdfd4bd79cbc28ff8d", size = 14888306, upload-time = "2026-10-09T04:18:43.722Z" },
{ url = "https://files.pythonhosted.org/packages/c9/93/d33bae7b1a78780c4946ce03989c59a67d42d7015ad62d2098975fc5a580/onnxruntime-1.31.0-cp313-cp313-win_arm64.whl", hash = "sha256:e51d10d2e2e1e5bbf9b126a0cd9853d3e6c4e21424518dd50160b91471be33dc", size = 14740892, upload-time = "2026-10-09T04:18:46.338Z" },
{ url = "https://files.pythonhosted.org/packages/12/05/cf44f7642269b285aada4b662c4662b14ac63f6e03e129d939c4a956a0f5/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e0e050bf9ec754950a6ba9830e4032f4004d972c6f38c5642fef26d44d894965", size = 21432644, upload-time = "2026-10-09T04:18:48.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/8e/673315b2dd2eb99b2f4774d7a5986fe00d933ebed17ee72c441f579226e6/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:e93d7c5fad20afa697ac16f376fd0306ed180f9a376e86106cc0b7d84f53ef87", size = 23773868, upload-time = "2026-10-09T04:18:51.776Z" },
{ url = "https://files.pythonhosted.org/packages/9d/fb/b4c52e500c6f3d00dfc22fad4d7513524f3ea2100a24a077ee3b0daf552d/onnxruntime-1.31.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:278e0dc922ec69b05a28f59110d5421e2ec8b1d0dd46c6b10c063069a4051e72", size = 20883462, upload-time = "2026-10-09T04:18:54.978Z" },
{ url = "https://files.pythonhosted.org/packages/37/fb/8be04665b700cb6e874d944e9932bb3c3969d3f53e820f5c42bfd26565d0/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:984c0a2c1ad6a41fbc101dc3949abe4a72254892d01a5e70d9b792711e0bfa54", size = 21421618, upload-time = "2026-10-09T04:18:58.1Z" },
{ url = "https://files.pythonhosted.org/packages/30/2e/5c6ec7e26a097e97ee70f2dee68b8ca4d9d26701f2f33c3f8ab585cb89fe/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4efa4a1a0bb0b5173c6a3292c181d518b8323f9d56e978635d0c09d38c94d1a", size = 23762993, upload-time = "2026-10-09T04:19:01.236Z" },
{ url = "https://files.pythonhosted.org/packages/6a/66/0bf4fdb9f58efa69cf4eddde24c72aebcc628d6ff1d67c9546145c6b9922/onnxruntime-1.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:83e3dbcf6abc6189c4bdf7d329c07ba1133c88172134c266d84b4409aa3b9dbf", size = 15268709, upload-time = "2026-10-09T04:19:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/af/99/75a36172c1ed1d74ac0e91c11d642548081e2c9c63f15ee796564619556f/onnxruntime-1.31.0-cp314-cp314-win_arm64.whl", hash = "sha256:d2d5ac22f896c810be2b2b171392bb908f80b6c9a7e2d592ddb7435c928044e1", size = 15153795, upload-time = "2026-10-09T04:19:06.609Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ec/23b7749edc7aad53bf4632de190399fda69a9195499426637ef1b02f06c6/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d25cd65874b75fdf16149120a04d0cd4551f860a3c8e2ecec785a1903e41d8aa", size = 21432344, upload-time = "2026-10-09T04:19:09.646Z" },
{ url = "https://files.pythonhosted.org/packages/f2/76/155ab0b265e9ceade28a8dd3858fdfa509b039f78010042c875940e32e58/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ecc1450af28d2cf362990e188ccc81b51388f317f641ad973ab4301473200f2", size = 23772576, upload-time = "2026-10-09T04:19:12.731Z" },
]
[[package]]
+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
@@ -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) {
@@ -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) {
+38
View File
@@ -0,0 +1,38 @@
final _whitespace = RegExp(r'\s+');
final _wordChar = RegExp(r'\w');
/// Builds the display name of a camera from its EXIF make and model.
///
/// Many cameras (e.g. Canon) already include the manufacturer in the model
/// (`Canon EOS R6m2`), so prefixing the make again would produce
/// "Canon Canon EOS R6m2". The make is only prepended when the model does not
/// already start with it. Makes made up of several words are matched word by
/// word, since the model usually repeats only part of them:
/// `NIKON CORPORATION` / `NIKON D850` and
/// `EASTMAN KODAK COMPANY` / `KODAK EASYSHARE C875`.
///
/// Returns `null` when neither a make nor a model is available.
String? getCameraName(String? make, String? model) {
final trimmedMake = make?.trim() ?? '';
final trimmedModel = model?.trim() ?? '';
if (trimmedMake.isEmpty && trimmedModel.isEmpty) {
return null;
}
if (trimmedMake.isEmpty) {
return trimmedModel;
}
if (trimmedModel.isEmpty) {
return trimmedMake;
}
final lowerModel = trimmedModel.toLowerCase();
final modelIncludesMake = trimmedMake.toLowerCase().split(_whitespace).any((word) {
final charAfterWord = lowerModel.length > word.length ? lowerModel[word.length] : '';
return lowerModel.startsWith(word) && !_wordChar.hasMatch(charAfterWord);
});
return modelIncludesMake ? trimmedModel : '$trimmedMake $trimmedModel';
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.3.5-local.13+3030505
version: 3.3.5-local.14+3030506
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -363,4 +363,65 @@ void main() {
expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id});
});
});
group('SyncStreamRepository - deleteAssetOcrV1 (#32337)', () {
test('removes OCR rows for deleted assets', () async {
await sut.updateUsersV1([_createUser()]);
final known1 = _createAsset(
id: 'known1',
checksum: 'AAA',
fileName: 'a.jpg',
);
final known2 = _createAsset(
id: 'known2',
checksum: 'BBB',
fileName: 'b.jpg',
);
await sut.updateAssetsV1([known1, known2]);
await sut.updateAssetOcrV1([
SyncAssetOcrV1(
assetId: known1.id,
boxScore: 0.9,
id: 'ocr-1',
isVisible: true,
text: 'this text',
textScore: 0.9,
x1: 1,
x2: 2,
x3: 3,
x4: 4,
y1: 1,
y2: 2,
y3: 3,
y4: 4,
),
SyncAssetOcrV1(
assetId: known2.id,
boxScore: 0.8,
id: 'ocr-2',
isVisible: false,
text: 'this text again',
textScore: 0.8,
x1: 1,
x2: 2,
x3: 3,
x4: 4,
y1: 1,
y2: 2,
y3: 3,
y4: 4,
),
]);
await sut.deleteAssetOcrV1([
SyncAssetOcrDeleteV1(id: 'log-id', assetId: known1.id, deletedAt: DateTime(2024, 1, 5)),
]);
final removedRows = await (db.assetOcrEntity.select()..where((t) => t.assetId.equals(known1.id))).get();
expect(removedRows, isEmpty);
final keepRows = await (db.assetOcrEntity.select()..where((t) => t.assetId.equals(known2.id))).get();
expect(keepRows, hasLength(1));
});
});
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/pages/search/search.page.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import '../../unit/presentation/presentation_context.dart';
/// Server info whose features arrive after the page is built, like on a slow or late connection
class DelayedServerInfoNotifier extends ServerInfoNotifier {
DelayedServerInfoNotifier(super.serverInfoService);
void enableSmartSearch() => state = state.copyWith(serverFeatures: state.serverFeatures.copyWith(smartSearch: true));
}
void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() async => context.dispose());
final contextSearchIcon = find.byIcon(Icons.image_search_rounded);
final filenameSearchIcon = find.byIcon(Icons.abc_rounded);
Future<DelayedServerInfoNotifier> pumpSearchPage(WidgetTester tester) async {
final serverInfo = DelayedServerInfoNotifier(context.service.serverInfo);
await tester.pumpTestWidget(
context,
const SearchPage(),
overrides: [serverInfoProvider.overrideWith((ref) => serverInfo)],
);
return serverInfo;
}
testWidgets('uses context search once smart search is reported by the server', (tester) async {
final serverInfo = await pumpSearchPage(tester);
expect(filenameSearchIcon, findsOneWidget);
serverInfo.enableSmartSearch();
await tester.pumpAndSettle();
expect(contextSearchIcon, findsOneWidget);
expect(filenameSearchIcon, findsNothing);
});
testWidgets('keeps the search type selected by the user', (tester) async {
final serverInfo = await pumpSearchPage(tester);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.text_snippet_outlined));
await tester.pumpAndSettle();
serverInfo.enableSmartSearch();
await tester.pumpAndSettle();
expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget);
expect(contextSearchIcon, findsNothing);
});
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/camera.utils.dart';
void main() {
group('getCameraName', () {
test('does not repeat the make when the model already starts with it', () {
expect(getCameraName('Canon', 'Canon EOS R6m2'), 'Canon EOS R6m2');
expect(getCameraName('Canon', 'Canon EOS 5D Mark IV'), 'Canon EOS 5D Mark IV');
});
test('compares the make and model case-insensitively', () {
expect(getCameraName('NIKON', 'Nikon Z 6'), 'Nikon Z 6');
expect(getCameraName('canon', 'CANON EOS R5'), 'CANON EOS R5');
});
test('compares against the first word of a multi-word make', () {
expect(getCameraName('NIKON CORPORATION', 'NIKON D850'), 'NIKON D850');
expect(getCameraName('OLYMPUS IMAGING CORP.', 'OLYMPUS E-M1'), 'OLYMPUS E-M1');
});
test('matches any word of a multi-word make', () {
expect(getCameraName('EASTMAN KODAK COMPANY', 'KODAK EASYSHARE C875'), 'KODAK EASYSHARE C875');
expect(getCameraName('EASTMAN KODAK COMPANY', 'EASTMAN KODAK 1'), 'EASTMAN KODAK 1');
});
test('prepends the make when the model does not include it', () {
expect(getCameraName('SONY', 'ILCE-7M4'), 'SONY ILCE-7M4');
expect(getCameraName('FUJIFILM', 'X-T5'), 'FUJIFILM X-T5');
expect(getCameraName('Apple', 'iPhone 15 Pro'), 'Apple iPhone 15 Pro');
});
test('only treats the make as a prefix on a word boundary', () {
expect(getCameraName('Canon', 'Canonical X1'), 'Canon Canonical X1');
});
test('handles a missing make or model', () {
expect(getCameraName('Canon', null), 'Canon');
expect(getCameraName(null, 'EOS R6m2'), 'EOS R6m2');
expect(getCameraName(null, null), isNull);
expect(getCameraName('', ''), isNull);
});
test('ignores surrounding whitespace', () {
expect(getCameraName(' Canon ', ' Canon EOS R6m2 '), 'Canon EOS R6m2');
});
});
}
+1
View File
@@ -34673,6 +34673,7 @@
"enum": [
"AssetCreate",
"AssetMetadataExtraction",
"AssetOcrExtraction",
"AssetTagged"
],
"type": "string"
+70
View File
@@ -75,6 +75,32 @@
}
],
"uiHints": ["SmartAlbum"]
},
{
"name": "receipts",
"title": "Receipts",
"description": "Automatically add receipt assets to an album",
"trigger": "AssetOcrExtraction",
"steps": [
{
"method": "immich-plugin-core#assetOcrFilter",
"config": {
"caseSensitive": false,
"pattern": "receipt.+total",
"matchType": "regex",
"matchScope": "full",
"minScore": 0
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumName": "Receipts",
"albumIds": []
}
}
],
"uiHints": ["SmartAlbum"]
}
],
"methods": [
@@ -428,6 +454,50 @@
},
"uiHints": ["Filter"]
},
{
"name": "assetOcrFilter",
"title": "Filter by OCR text",
"description": "Filter assets by OCR text pattern using text matching or regular expressions",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"title": "OCR text pattern",
"description": "Text or regex pattern to match against OCR text"
},
"matchType": {
"type": "string",
"enum": ["contains", "startsWith", "exact", "regex"],
"default": "contains",
"title": "Match type",
"description": "Type of pattern matching to perform"
},
"caseSensitive": {
"type": "boolean",
"default": false,
"title": "Case sensitive",
"description": "Whether matching should be case-sensitive"
},
"minScore": {
"type": "number",
"default": 0,
"title": "Minimum score",
"description": "Minimum OCR confidence score to match"
},
"matchScope": {
"type": "string",
"enum": ["full", "box"],
"default": "full",
"title": "Match scope",
"description": "Scope of the OCR text to match against"
}
},
"required": ["pattern"]
},
"uiHints": ["Filter"]
},
{
"name": "assetArchive",
"title": "Archive asset",
+9
View File
@@ -194,6 +194,13 @@ const methods = wrapper<Manifest>({
return { workflow: { continue: hasTimeZone === needsTimeZone } };
},
assetOcrFilter: ({ config, data }) => {
const entries = (data.asset.ocr ?? []).filter((ocr) => ocr.textScore >= (config.minScore ?? 0));
const texts = config.matchScope === 'full' ? [entries.map((ocr) => ocr.text).join(' ')] : entries.map((ocr) => ocr.text);
const matched = texts.some((t) => matchValueResult(t, config).workflow?.continue);
return { workflow: { continue: matched } };
},
assetTagFilter: ({ config, data }) => {
const assetTags = data.asset.tags.map((tag) => tag.id);
@@ -254,6 +261,7 @@ const {
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
assetOcrFilter,
assetTagFilter,
assetTypeFilter,
assetVisibility,
@@ -274,6 +282,7 @@ export {
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
assetOcrFilter,
assetTagFilter,
assetTypeFilter,
assetVisibility,
+6
View File
@@ -23,6 +23,7 @@ export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
AssetOcrExtraction = 'AssetOcrExtraction',
AssetTagged = 'AssetTagged',
// PersonRecognized = 'PersonRecognized',
}
@@ -127,6 +128,11 @@ export type AssetV1 = {
tags: string[] | null;
updatedAt: string | null;
} | null;
ocr?: {
text: string;
textScore: number;
boxScore: number;
}[]
};
};
+1
View File
@@ -8603,6 +8603,7 @@ export enum WorkflowType {
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
AssetOcrExtraction = "AssetOcrExtraction",
AssetTagged = "AssetTagged"
}
export enum QueueJobStatus {
+29 -5
View File
@@ -961,7 +961,7 @@ importers:
version: link:../packages/sdk
'@immich/ui':
specifier: ^0.91.0
version: 0.91.0(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))
version: 0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3)
'@mdi/js':
specifier: ^7.4.47
version: 7.4.47
@@ -3163,8 +3163,8 @@ packages:
resolution: {integrity: sha512-NXxDO0jIzXmNvJAR+somSmN81zOP+11DboGaXRMxxpcZVRBIrWpwGPLZ3dG7SZfpyccKlgZGRBWqQGRm9U5A4w==}
hasBin: true
'@immich/ui@0.91.0':
resolution: {integrity: sha512-68TGDhVdv0Ezff148+grTFHV0NyrfQ464OXg0p9e6ISxUBcfqAiP/i62LbVWsork/gj+G2a9WnKeJrLrv6YWyA==}
'@immich/ui@0.91.1':
resolution: {integrity: sha512-RIicH0+vQxrfCd4Pm1uVwQhmBM1zHsCmSe9k1OmTPDEe18m8grJ8dTQe0so8cg5CjJfN6riYiT0b2Rqq6+6IOA==}
peerDependencies:
'@sveltejs/kit': ^2.13.0 || ^3.0.0
svelte: ^5.0.0
@@ -11698,6 +11698,18 @@ packages:
'@sveltejs/kit':
optional: true
runed@0.37.1:
resolution: {integrity: sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==}
peerDependencies:
'@sveltejs/kit': ^2.21.0
svelte: ^5.7.0
zod: ^4.1.0
peerDependenciesMeta:
'@sveltejs/kit':
optional: true
zod:
optional: true
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
@@ -16226,7 +16238,7 @@ snapshots:
pg-connection-string: 2.14.0
postgres: 3.4.9
'@immich/ui@0.91.0(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))':
'@immich/ui@0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3)':
dependencies:
'@internationalized/date': 3.12.2
'@mdi/js': 7.4.47
@@ -16234,13 +16246,15 @@ snapshots:
bits-ui: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))
fuse.js: 7.5.0
luxon: 3.7.2
runed: 0.35.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))
runed: 0.37.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3)
simple-icons: 16.32.0
svelte: 5.57.2(@typescript-eslint/types@8.70.1)
svelte-highlight: 7.11.0
tailwind-merge: 3.7.0
tailwind-variants: 3.3.1(tailwind-merge@3.7.0)(tailwindcss@4.3.3)
tailwindcss: 4.3.3
transitivePeerDependencies:
- zod
'@inquirer/ansi@2.0.8': {}
@@ -25875,6 +25889,16 @@ snapshots:
optionalDependencies:
'@sveltejs/kit': 3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1))
runed@0.37.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.57.2(@typescript-eslint/types@8.70.1)
optionalDependencies:
'@sveltejs/kit': 3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1))
zod: 4.4.3
rw@1.3.3: {}
rxjs@7.8.2:
-10
View File
@@ -115,13 +115,3 @@ password: demo
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
## Star History
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
-10
View File
@@ -115,16 +115,6 @@
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## История на звездите
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Сътрудници
<a href="https://github.com/immich-app/immich/graphs/contributors">
-10
View File
@@ -118,16 +118,6 @@ Mehr zum Thema Übersetzungen kannst du [hier](https://docs.immich.app/developer
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## Github Sterne
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Mitwirkende
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -110,16 +110,6 @@ Lea mas acerca de las traducciones [acá](https://docs.immich.app/developer/tran
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## Historial de Estrellas
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Contribuidores
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -111,13 +111,3 @@ mot de passe: demo
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
## Historique des favoris
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
-10
View File
@@ -117,16 +117,6 @@ Scopri di più sulle traduzioni [qui](https://docs.immich.app/developer/translat
![Attività](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Immagine analisi repobeats")
## Cronologia delle stelle
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Grafico storico delle stelle" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Contributori
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -115,16 +115,6 @@
![활동](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## 스타 기록
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="스타 기록 차트" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## 기여자
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -117,16 +117,6 @@
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## സ്റ്റാർ ചരിത്രം
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
</picture>
</a>
## സംഭാവന ചെയ്തവർ
<a href="https://github.com/immich-app/immich/graphs/contributors">
-10
View File
@@ -109,16 +109,6 @@ Je kunt [hier](https://docs.immich.app/developer/translations) meer over vertali
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## Ster geschiedenis
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Contributie-leden
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -122,16 +122,6 @@ Leia mais sobre as traduções
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de análise de atividade Repobeats")
## Histórico de estrelas
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Gráfico de histórico de estrelas" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Contribuidores
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -117,16 +117,6 @@
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## История звёзд
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Участники
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -113,13 +113,3 @@ lösenord: demo
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
## Stjärn-Historik
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
-10
View File
@@ -119,16 +119,6 @@
![กิจกรรม](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "ภาพการวิเคราะห์ของ Repobeats")
## ประวัติการให้ดาว
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="แผนภูมิประวัติการให้ดาว" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## ผู้ร่วมพัฒนา
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -117,16 +117,6 @@
![Діяльність](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Зображення аналітики Repobeats")
## Історія зірок
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Автори
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -117,16 +117,6 @@ Truy cập bản demo [tại đây](https://demo.immich.app). Đối với ứng
![Hoạt động](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Hình ảnh phân tích Repobeats")
## Lịch sử Đánh dấu sao
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Biểu đồ Lịch sử Đánh dấu" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Người đóng góp
<a href="https://github.com/alextran1502/immich/graphs/contributors">
-10
View File
@@ -118,16 +118,6 @@
![專案活躍度](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats 分析圖片")
## Star 數量歷史紀錄
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star 歷史紀錄圖表" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## 貢獻者
<a href="https://github.com/alextran1502/immich/graphs/contributors">
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS builder
FROM ghcr.io/immich-app/base-server-dev:202610291416@sha256:5f4f9f77059e1cd0182994419a19f4ee045c3fd48e611f5e77623ed63f7aa1e4 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -86,7 +86,7 @@ RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202610211607@sha256:e50a1b36e2638dce42981ddfa84c134246c48fb2ad290fab6acfc1bb9b2bd891
FROM ghcr.io/immich-app/base-server-prod:202610291416@sha256:6211bec52cbccc19076889b0c39ef473a1a1d02ceb635ccd2e0ecb4683e5fd4d
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS dev
FROM ghcr.io/immich-app/base-server-dev:202610291416@sha256:5f4f9f77059e1cd0182994419a19f4ee045c3fd48e611f5e77623ed63f7aa1e4 AS dev
COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise
+43
View File
@@ -1,8 +1,10 @@
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import z from 'zod';
import type { UserMetadataItem } from 'src/types.js';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
import { ImmichCookie, UserMetadataKey } from 'src/enum.js';
import { HumanReadableSize } from 'src/utils/bytes.js';
import { toEmail } from 'src/validation.js';
export type CookieResponse = {
@@ -23,6 +25,47 @@ export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
const storageLabelClaimSchema = z.string().transform((label) => sanitize(label));
const quotaSizeInBytesSchema = z
.int()
.min(-1)
.nullable()
.transform((quota) => (quota === null || quota === -1 ? null : quota * HumanReadableSize.GiB));
const userRoleSchema = z.union([z.literal('user'), z.literal('admin')]);
export const OAuthProfileSchema = z.object({
defaults: z.object({
immich_quota_bytes: quotaSizeInBytesSchema,
}),
sub: z.string(),
email: z.string().trim().toLowerCase().optional().catch(undefined),
email_verified: z.coerce.boolean().optional().catch(undefined),
name: z.string().trim().optional().catch(undefined),
given_name: z.string().trim().optional().catch(undefined),
family_name: z.string().trim().optional().catch(undefined),
preferred_username: z.string().trim().optional().catch(undefined),
picture: z.string().optional().catch(undefined),
immich_storage_label: storageLabelClaimSchema.optional().catch(undefined),
immich_quota_bytes: quotaSizeInBytesSchema.optional().catch(undefined),
immich_role: z
.union([userRoleSchema, z.array(userRoleSchema)])
.transform((value) => {
if (!value) {
return;
}
const roles = Array.isArray(value) ? value : [value];
if (roles.includes('admin')) {
return 'admin';
}
return 'user';
})
.optional()
.catch(undefined),
});
export type OAuthProfile = z.infer<typeof OAuthProfileSchema>;
const LoginCredentialSchema = z
.object({
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
@@ -615,6 +615,7 @@ where
-- AssetJobRepository.getForOcr
select
"asset"."ownerId",
"asset"."visibility",
(
select
@@ -255,7 +255,7 @@ export class AssetJobRepository {
getForOcr(id: string) {
return this.db
.selectFrom('asset')
.select((eb) => ['asset.visibility', withFilePath(eb, AssetFileType.Preview).as('previewFile')])
.select((eb) => ['asset.ownerId', 'asset.visibility', withFilePath(eb, AssetFileType.Preview).as('previewFile')])
.where('asset.id', '=', id)
.executeTakeFirst();
}
@@ -53,6 +53,7 @@ type EventMap = {
AssetTrash: [{ assetId: string; userId: string }];
AssetDelete: [{ assetId: string; userId: string }];
AssetMetadataExtracted: [{ assetId: string; userId: string; source?: JobSource }];
AssetOcrExtracted: [{ assetId: string; userId: string }];
// asset bulk events
AssetTrashAll: [{ assetIds: string[]; userId: string }];
+30 -24
View File
@@ -15,26 +15,32 @@ import {
randomState,
skipSubjectCheck,
} from 'openid-client';
import { SystemConfig } from 'src/dtos/config.dto.js';
import { OAuthTokenEndpointAuthMethod } from 'src/enum.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
export type OAuthConfig = {
clientId: string;
clientSecret?: string;
issuerUrl: string;
accountManagementUrl: string;
endSessionEndpoint: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
profileSigningAlgorithm: string;
prompt: string;
scope: string;
signingAlgorithm: string;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
timeout: number;
allowInsecureRequests: boolean;
};
export type OAuthProfile = UserInfoResponse;
export type OAuthConfig = Pick<
SystemConfig['oauth'],
| 'defaultStorageQuota'
| 'storageLabelClaim'
| 'storageQuotaClaim'
| 'roleClaim'
| 'clientId'
| 'clientSecret'
| 'issuerUrl'
| 'accountManagementUrl'
| 'endSessionEndpoint'
| 'mobileOverrideEnabled'
| 'mobileRedirectUri'
| 'profileSigningAlgorithm'
| 'prompt'
| 'scope'
| 'signingAlgorithm'
| 'tokenEndpointAuthMethod'
| 'timeout'
| 'allowInsecureRequests'
>;
export type OAuthRawProfile = UserInfoResponse;
@Injectable()
export class OAuthRepository {
@@ -84,23 +90,23 @@ export class OAuthRepository {
url: string,
expectedState: string,
codeVerifier: string,
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
): Promise<{ rawProfile: OAuthRawProfile; sid?: string; idToken?: string }> {
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
try {
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
let profile: OAuthProfile;
let rawProfile: OAuthRawProfile;
const tokenClaims = tokens.claims();
if (tokenClaims && 'email' in tokenClaims) {
this.logger.debug('Using ID token claims instead of userinfo endpoint');
profile = tokenClaims as OAuthProfile;
rawProfile = tokenClaims;
} else {
profile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
rawProfile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
}
if (!profile.sub) {
if (!rawProfile.sub) {
throw new Error('Unexpected profile response, no `sub`');
}
@@ -112,7 +118,7 @@ export class OAuthRepository {
}
}
return { profile, sid, idToken: tokens.id_token };
return { rawProfile, sid, idToken: tokens.id_token };
} catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn(
@@ -138,7 +144,7 @@ export class OAuthRepository {
return response.arrayBuffer();
}
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performnce
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performance
async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> {
const client = await this.getClient(config);
const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256';
@@ -27,6 +27,16 @@ describe(ProcessRepository.name, () => {
});
});
describe('isCommandAvailable', () => {
it('should find an executable on the PATH', async () => {
await expect(sut.isCommandAvailable('bash')).resolves.toBe(true);
});
it('should not find a missing executable', async () => {
await expect(sut.isCommandAvailable('immich-missing-command')).resolves.toBe(false);
});
});
describe('createSpawnDuplexStream', () => {
it('should work (drain to stdout)', async () => {
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
@@ -1,10 +1,27 @@
import { Injectable } from '@nestjs/common';
import { SpawnOptionsWithoutStdio, fork, spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { availableParallelism } from 'node:os';
import path from 'node:path';
import { Duplex } from 'node:stream';
@Injectable()
export class ProcessRepository {
spawn = spawn;
availableParallelism = availableParallelism;
async isCommandAvailable(command: string): Promise<boolean> {
const directories = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
const found = await Promise.all(
directories.map((directory) =>
access(path.join(directory, command), constants.X_OK)
.then(() => true)
.catch(() => false),
),
);
return found.includes(true);
}
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
let isStdinClosed = false;
+13 -1
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { Injectable } from '@nestjs/common';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
@@ -177,7 +178,7 @@ export class WorkflowRepository {
await this.db.deleteFrom('workflow').where('id', '=', id).execute();
}
getForAssetV1(assetId: string) {
getForAssetV1(assetId: string, trigger: WorkflowTrigger) {
return this.db
.selectFrom('asset')
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
@@ -221,6 +222,17 @@ export class WorkflowRepository {
])
.whereRef('asset_exif.assetId', '=', 'asset.id'),
).as('exifInfo'),
...(trigger === WorkflowTrigger.AssetOcrExtraction
? [
jsonArrayFrom(
eb
.selectFrom('asset_ocr')
.select(['asset_ocr.text', 'asset_ocr.textScore', 'asset_ocr.boxScore'])
.whereRef('asset_ocr.assetId', '=', 'asset.id')
.where('isVisible', '=', true),
).as('ocr'),
]
: []),
])
.where('id', '=', assetId)
.executeTakeFirstOrThrow();
+86 -53
View File
@@ -719,7 +719,7 @@ describe(AuthService.name, () => {
it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
await expect(
sut.callback(
@@ -734,10 +734,10 @@ describe(AuthService.name, () => {
it('should link an existing user', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
const rawProfile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -749,15 +749,19 @@ describe(AuthService.name, () => {
);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
});
it('should store the OAuth bearer token on the new session', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
const rawProfile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile,
sid: 'oauth-sid',
idToken: 'oauth-bearer-token',
});
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -775,10 +779,10 @@ describe(AuthService.name, () => {
it('should normalize the email from the OAuth profile before linking', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
const rawProfile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -790,14 +794,14 @@ describe(AuthService.name, () => {
);
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
});
it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -818,7 +822,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
@@ -837,7 +841,7 @@ describe(AuthService.name, () => {
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' } });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: { sub: 'sub' } });
await expect(
sut.callback(
@@ -859,7 +863,7 @@ describe(AuthService.name, () => {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
@@ -877,7 +881,9 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create(),
});
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -893,7 +899,7 @@ describe(AuthService.name, () => {
it('should infer name from given and family names', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
rawProfile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -910,10 +916,10 @@ describe(AuthService.name, () => {
});
it('should fallback to email when no username is provided', async () => {
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
const rawProfile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
@@ -925,13 +931,13 @@ describe(AuthService.name, () => {
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: rawProfile.email }));
});
it('should ignore an invalid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
rawProfile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
@@ -950,7 +956,7 @@ describe(AuthService.name, () => {
it('should ignore an invalid negative quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
rawProfile: OAuthProfileFactory.create({ immich_quota: -5 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
@@ -969,7 +975,7 @@ describe(AuthService.name, () => {
it('should set unlimited quota for -1', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -1 }),
rawProfile: OAuthProfileFactory.create({ immich_quota: -1 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
@@ -987,7 +993,9 @@ describe(AuthService.name, () => {
it('should set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create({ immich_quota: 0 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -1004,7 +1012,9 @@ describe(AuthService.name, () => {
it('should use a valid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create({ immich_quota: 5 }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1023,11 +1033,11 @@ describe(AuthService.name, () => {
it('should sync the profile picture', async () => {
const fileId = newUuid();
const user = UserFactory.create({ oauthId: 'oauth-id' });
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
const rawProfile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
const pictureBytes = new Uint8Array([1, 2, 3, 4, 5]);
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue(pictureBytes.buffer);
@@ -1044,7 +1054,7 @@ describe(AuthService.name, () => {
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
profileChangedAt: expect.any(Date),
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(rawProfile.picture);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(
Buffer.from(pictureBytes.buffer, pictureBytes.byteOffset, pictureBytes.byteLength),
expect.objectContaining({ processInvalidImages: false }),
@@ -1058,10 +1068,10 @@ describe(AuthService.name, () => {
it('should not update the user when thumbnail processing fails on the OAuth picture', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
const rawProfile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.oauth.getProfilePicture.mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]).buffer);
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
@@ -1085,7 +1095,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({
rawProfile: OAuthProfileFactory.create({
sub: oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
@@ -1108,7 +1118,7 @@ describe(AuthService.name, () => {
it('should only allow "admin" and "user" for the role claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
rawProfile: OAuthProfileFactory.create({ immich_role: 'foo' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -1128,7 +1138,7 @@ describe(AuthService.name, () => {
it('should create an admin user if the role claim is set to admin', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
rawProfile: OAuthProfileFactory.create({ immich_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1149,7 +1159,7 @@ describe(AuthService.name, () => {
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
});
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
rawProfile: OAuthProfileFactory.create({ my_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1168,7 +1178,7 @@ describe(AuthService.name, () => {
it('should create an admin user if the role claim is an array containing admin', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }),
rawProfile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1187,7 +1197,7 @@ describe(AuthService.name, () => {
it('should create a standard user if the role claim is an array containing only user', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: ['user'] }),
rawProfile: OAuthProfileFactory.create({ immich_role: ['user'] }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1209,7 +1219,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: 5 }),
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: 5 }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 });
@@ -1233,7 +1243,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: -1 }),
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: -1 }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: null });
@@ -1257,7 +1267,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, preferred_username: 'idp-username' }),
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, preferred_username: 'idp-username' }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -1279,7 +1289,7 @@ describe(AuthService.name, () => {
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
});
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced-label' }),
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced-label' }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.getByStorageLabel.mockResolvedValue(void 0);
@@ -1305,7 +1315,7 @@ describe(AuthService.name, () => {
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
});
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced.label' }),
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced.label' }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.getByStorageLabel.mockResolvedValue(void 0);
@@ -1330,7 +1340,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: 'admin' }),
rawProfile: OAuthProfileFactory.create({ sub: oauthId, immich_role: 'admin' }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: true });
@@ -1351,7 +1361,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: ['user'] }),
rawProfile: OAuthProfileFactory.create({ sub: oauthId, immich_role: ['user'] }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: false });
@@ -1372,7 +1382,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: oauthId }),
rawProfile: OAuthProfileFactory.create({ sub: oauthId }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -1391,7 +1401,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId! }),
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId! }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -1406,15 +1416,38 @@ describe(AuthService.name, () => {
expect(mocks.user.syncUsage).not.toHaveBeenCalled();
});
it('should update quota to default value on subsequent logins when it is absent from the profile', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId! }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValueOnce({ ...user, quotaSizeInBytes: null });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(
user.id,
expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }),
);
});
it('should re-evaluate the role claim for a user linked by email', async () => {
const user = UserFactory.create({ isAdmin: false });
const profile = OAuthProfileFactory.create({ immich_role: 'admin' });
const rawProfile = OAuthProfileFactory.create({ immich_role: 'admin' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub });
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub, isAdmin: true });
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub });
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub, isAdmin: true });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
@@ -1423,7 +1456,7 @@ describe(AuthService.name, () => {
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true });
});
});
@@ -1432,10 +1465,10 @@ describe(AuthService.name, () => {
it('should link an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create();
const rawProfile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.update.mockResolvedValue(user);
await sut.link(
@@ -1444,7 +1477,7 @@ describe(AuthService.name, () => {
{},
);
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: rawProfile.sub });
});
it('should link an account and update the session with the oauthSid', async () => {
@@ -1454,7 +1487,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
rawProfile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
idToken: session.oauthBearerToken ?? undefined,
});
@@ -1480,7 +1513,7 @@ describe(AuthService.name, () => {
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(
+52 -104
View File
@@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
import { parseCookie } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import sanitize from 'sanitize-filename';
import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants.js';
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
import {
@@ -14,6 +13,8 @@ import {
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
OAuthProfile,
OAuthProfileSchema,
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
@@ -23,10 +24,9 @@ import {
import { SystemConfig, defaults } from 'src/dtos/config.dto.js';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum.js';
import { OAuthProfile } from 'src/repositories/oauth.repository.js';
import { OAuthConfig, OAuthRawProfile } from 'src/repositories/oauth.repository.js';
import { BaseService } from 'src/services/base.service.js';
import { isGranted } from 'src/utils/access.js';
import { HumanReadableSize } from 'src/utils/bytes.js';
import { generateProfileImage } from 'src/utils/profile-image.js';
import { getUserAgentDetails } from 'src/utils/request.js';
@@ -38,18 +38,10 @@ export interface LoginDetails {
appVersion: string | null;
}
interface ClaimOptions<T> {
key: string;
isValid: (value: unknown) => value is T;
parse?: (raw: unknown) => T;
}
type OAuthClaimsConfig = Pick<SystemConfig['oauth'], 'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim'>;
interface ParsedOAuthClaims {
storageLabel?: string | null;
quotaSizeInBytes?: number | null;
}
type OAuthClaimsConfig = Pick<
SystemConfig['oauth'],
'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim' | 'roleClaim'
>;
export type ValidateRequest = {
headers: IncomingHttpHeaders;
@@ -294,6 +286,18 @@ export class AuthService extends BaseService {
);
}
private parseOAuthClaims(profile: OAuthRawProfile, oauth: OAuthConfig) {
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
return OAuthProfileSchema.parse({
...profile,
defaults: { immich_quota_bytes: defaultStorageQuota },
immich_storage_label: profile[storageLabelClaim],
immich_quota_bytes: profile[storageQuotaClaim],
immich_role: profile[roleClaim],
});
}
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
@@ -312,18 +316,18 @@ export class AuthService extends BaseService {
const url = this.resolveRedirectUri(oauth, dto.url);
const {
profile,
rawProfile,
sid: oauthSid,
idToken: oauthBearerToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, roleClaim } = oauth;
const profile = this.parseOAuthClaims(rawProfile, oauth);
const { autoRegister } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
// link by email
if (!user && normalizedEmail) {
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
if (!user && profile.email) {
const emailUser = await this.userRepository.getByEmail(profile.email);
if (emailUser) {
if (emailUser.oauthId) {
this.logger.debug('OAuth login conflict: email already linked to different account');
@@ -333,10 +337,9 @@ export class AuthService extends BaseService {
}
}
const role = this.getRoleClaim(profile, roleClaim);
const isAdmin = role === 'admin';
const isAdmin = profile.immich_role === 'admin';
if (user && role && isAdmin !== user.isAdmin) {
if (user && profile.immich_role && isAdmin !== user.isAdmin) {
user = await this.userRepository.update(user.id, { isAdmin });
}
@@ -346,29 +349,28 @@ export class AuthService extends BaseService {
} else {
if (!autoRegister) {
this.logger.warn(
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
`Unable to register ${profile.sub}/${profile.email || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException('OAuth authentication failed');
}
if (!normalizedEmail) {
if (!profile.email) {
throw new BadRequestException('OAuth profile does not have an email address');
}
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`);
const claims = this.parseOAuthClaims(profile, oauth, true);
this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`);
user = await this.createUser({
name:
profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
normalizedEmail,
email: normalizedEmail,
profile.email,
email: profile.email,
oauthId: profile.sub,
quotaSizeInBytes: claims.quotaSizeInBytes ?? null,
storageLabel: claims.storageLabel ?? null,
quotaSizeInBytes:
profile.immich_quota_bytes === undefined ? profile.defaults.immich_quota_bytes : profile.immich_quota_bytes,
storageLabel: profile.immich_storage_label ?? null,
isAdmin,
});
}
@@ -415,11 +417,13 @@ export class AuthService extends BaseService {
}
const { oauth } = await this.getConfig({ withCache: false });
const {
profile: { sub: oauthId },
sid,
idToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const { rawProfile, sid, idToken } = await this.oauthRepository.getProfileAndOAuthSid(
oauth,
dto.url,
expectedState,
codeVerifier,
);
const { sub: oauthId } = this.parseOAuthClaims(rawProfile, oauth);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
@@ -611,73 +615,30 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T | undefined {
const raw = profile[options.key as keyof OAuthProfile];
const value = options.parse ? options.parse(raw) : raw;
return options.isValid(value) ? value : undefined;
}
private parseOAuthClaims(profile: OAuthProfile, oauth: OAuthClaimsConfig, useDefaults: boolean): ParsedOAuthClaims {
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
const claims: ParsedOAuthClaims = {};
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
isValid: (value): value is string => typeof value === 'string',
});
if (useDefaults) {
claims.storageLabel = storageLabel || null;
}
// Default claim (preferred_username) is registration-only; login sync is opt-in.
else if (storageLabelClaim !== defaults.oauth.storageLabelClaim && storageLabel !== undefined) {
claims.storageLabel = this.formatStorageLabel(storageLabel);
}
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
parse: Number,
isValid: (value): value is number =>
typeof value === 'number' && Number.isFinite(value) && (value === -1 || value >= 0),
});
if (storageQuota !== undefined) {
claims.quotaSizeInBytes = storageQuota === -1 ? null : storageQuota * HumanReadableSize.GiB;
} else if (useDefaults) {
claims.quotaSizeInBytes =
defaultStorageQuota === null || defaultStorageQuota === -1 ? null : defaultStorageQuota * HumanReadableSize.GiB;
}
return claims;
}
private formatStorageLabel(label: string): string | null {
if (!label) {
return null;
}
return sanitize(label);
}
private async syncOAuthClaims(user: UserAdmin, profile: OAuthProfile, oauth: OAuthClaimsConfig): Promise<UserAdmin> {
const claims = this.parseOAuthClaims(profile, oauth, false);
const updates: {
storageLabel?: string | null;
quotaSizeInBytes?: number | null;
} = {};
if (claims.storageLabel && claims.storageLabel !== user.storageLabel) {
const duplicate = await this.userRepository.getByStorageLabel(claims.storageLabel);
if (
oauth.storageLabelClaim !== defaults.oauth.storageLabelClaim &&
profile.immich_storage_label &&
profile.immich_storage_label !== user.storageLabel
) {
const duplicate = await this.userRepository.getByStorageLabel(profile.immich_storage_label);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`);
} else {
updates.storageLabel = claims.storageLabel;
updates.storageLabel = profile.immich_storage_label;
}
}
if (claims.quotaSizeInBytes !== undefined && claims.quotaSizeInBytes !== user.quotaSizeInBytes) {
updates.quotaSizeInBytes = claims.quotaSizeInBytes;
if (profile.immich_quota_bytes === undefined && user.quotaSizeInBytes !== profile.defaults.immich_quota_bytes) {
updates.quotaSizeInBytes = profile.defaults.immich_quota_bytes;
} else if (profile.immich_quota_bytes !== undefined && profile.immich_quota_bytes !== user.quotaSizeInBytes) {
updates.quotaSizeInBytes = profile.immich_quota_bytes;
}
if (Object.keys(updates).length === 0) {
@@ -691,19 +652,6 @@ export class AuthService extends BaseService {
return this.userRepository.update(user.id, { ...updates, updatedAt: new Date() });
}
private getRoleClaim(profile: OAuthProfile, roleClaim: string): 'admin' | 'user' | undefined {
const value = profile[roleClaim as keyof OAuthProfile];
const roles = Array.isArray(value) ? value : [value];
const isRole = (role: string) => roles.includes(role);
if (isRole('admin')) {
return 'admin';
}
if (isRole('user')) {
return 'user';
}
}
private resolveRedirectUri(
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
url: string,
@@ -16,6 +16,7 @@ describe(DatabaseBackupService.name, () => {
beforeEach(() => {
mocks = getMocks();
mocks.process.isCommandAvailable.mockResolvedValue(false);
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
args: [mocks.logger],
strict: false,
@@ -228,6 +229,19 @@ describe(DatabaseBackupService.name, () => {
expect(mocks.storage.rename).toHaveBeenCalled();
});
it('should compress with gzip when pigz is not installed', async () => {
await sut.handleBackupDatabase();
expect(mocks.process.isCommandAvailable).toHaveBeenCalledWith('pigz');
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('gzip', ['--rsyncable']);
});
it('should compress with pigz on every available core when it is installed', async () => {
mocks.process.isCommandAvailable.mockResolvedValue(true);
mocks.process.availableParallelism.mockReturnValue(8);
await sut.handleBackupDatabase();
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('pigz', ['--rsyncable', '--processes', '8']);
});
it('should fail if pg_dump fails', async () => {
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex()('pg_dump', 1, '', 'error'));
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
+12 -1
View File
@@ -236,6 +236,8 @@ export class DatabaseBackupService {
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
const temporaryFilePath = `${backupFilePath}.tmp`;
const compression = await this.getCompressionCommand();
let pgdump: Duplex | undefined;
let gzip: Duplex | undefined;
@@ -247,7 +249,7 @@ export class DatabaseBackupService {
},
});
gzip = this.processRepository.spawnDuplexStream('gzip', ['--rsyncable']);
gzip = this.processRepository.spawnDuplexStream(compression.command, compression.args);
const fileStream = this.storageRepository.createWriteStream(temporaryFilePath);
await pipeline(pgdump, gzip, fileStream);
@@ -267,6 +269,15 @@ export class DatabaseBackupService {
return backupFilePath;
}
private async getCompressionCommand() {
if (await this.processRepository.isCommandAvailable('pigz')) {
const processes = this.processRepository.availableParallelism();
return { command: 'pigz', args: ['--rsyncable', '--processes', processes.toString()] };
}
return { command: 'gzip', args: ['--rsyncable'] };
}
async uploadBackup(file: Express.Multer.File): Promise<void> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const fn = basename(file.originalname);
+23 -1
View File
@@ -13,6 +13,7 @@ describe(OcrService.name, () => {
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
mocks.assetJob.getForOcr.mockResolvedValue({
ownerId: 'user-id',
visibility: AssetVisibility.Timeline,
previewFile: '/uploads/user-id/thumbs/path.jpg',
});
@@ -69,16 +70,22 @@ describe(OcrService.name, () => {
expect(mocks.asset.getByIds).not.toHaveBeenCalled();
expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should skip assets without a resize path', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Timeline, previewFile: null });
mocks.assetJob.getForOcr.mockResolvedValue({
ownerId: 'userId',
visibility: AssetVisibility.Timeline,
previewFile: null,
});
expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Failed);
expect(mocks.ocr.upsert).not.toHaveBeenCalled();
expect(mocks.machineLearning.ocr).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should save the returned objects', async () => {
@@ -170,6 +177,7 @@ describe(OcrService.name, () => {
it('should skip invisible assets', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
mocks.assetJob.getForOcr.mockResolvedValue({
ownerId: 'userId',
visibility: AssetVisibility.Hidden,
previewFile: asset.files[0].path,
});
@@ -178,6 +186,7 @@ describe(OcrService.name, () => {
expect(mocks.machineLearning.ocr).not.toHaveBeenCalled();
expect(mocks.ocr.upsert).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should fail if asset could not be found', async () => {
@@ -187,6 +196,19 @@ describe(OcrService.name, () => {
expect(mocks.machineLearning.ocr).not.toHaveBeenCalled();
expect(mocks.ocr.upsert).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should emit an event', async () => {
const asset = AssetFactory.create();
mockOcrResult('Hello World');
await sut.handleOcr({ id: asset.id });
expect(mocks.event.emit).toHaveBeenCalledWith('AssetOcrExtracted', {
userId: 'user-id',
assetId: asset.id,
});
});
describe('search tokenization', () => {
+5
View File
@@ -50,6 +50,11 @@ export class OcrService extends BaseService {
await this.assetRepository.upsertJobStatus({ assetId: id, ocrAt: new Date() });
await this.eventRepository.emit('AssetOcrExtracted', {
userId: asset.ownerId,
assetId: id,
});
this.logger.debug(`Processed ${ocrResults.text.length} OCR result(s) for ${id}`);
return JobStatus.Success;
}
@@ -1213,6 +1213,66 @@ describe(PersonService.name, () => {
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
});
it('should match metadata face stored at a different resolution', async () => {
const face = AssetFaceFactory.create({
sourceType: SourceType.Exif,
imageWidth: 2736,
imageHeight: 3648,
boundingBoxX1: 1225,
boundingBoxY1: 1453,
boundingBoxX2: 1436,
boundingBoxY2: 1732,
});
const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).exif().build();
mocks.machineLearning.detectFaces.mockResolvedValue({
imageWidth: 1440,
imageHeight: 1920,
faces: [{ boundingBox: { x1: 645, y1: 765, x2: 756, y2: 912 }, embedding: '[1, 2, 3, 4]', score: 0.9 }],
});
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
await sut.handleDetectFaces({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [], [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }]);
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
it('should prefer metadata face over overlapping machine learning face', async () => {
const mlFace = AssetFaceFactory.create({
imageWidth: 1440,
imageHeight: 1920,
boundingBoxX1: 645,
boundingBoxY1: 765,
boundingBoxX2: 756,
boundingBoxY2: 912,
});
const exifFace = AssetFaceFactory.create({
sourceType: SourceType.Exif,
imageWidth: 2736,
imageHeight: 3648,
boundingBoxX1: 1225,
boundingBoxY1: 1453,
boundingBoxX2: 1436,
boundingBoxY2: 1732,
});
const asset = AssetFactory.from()
.face(mlFace)
.face(exifFace)
.file({ type: AssetFileType.Preview })
.exif()
.build();
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(mlFace));
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
await sut.handleDetectFaces({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[],
[mlFace.id],
[{ faceId: exifFace.id, embedding: '[1, 2, 3, 4]' }],
);
});
it('should not add embedding to non-matching metadata face', async () => {
const assetId = newUuid();
const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif });
+32 -16
View File
@@ -414,16 +414,10 @@ export class PersonService extends BaseService {
}
}
const heightScale = imageHeight / (asset.faces[0]?.imageHeight || 1);
const widthScale = imageWidth / (asset.faces[0]?.imageWidth || 1);
for (const { boundingBox, embedding } of faces) {
const scaledBox = {
x1: boundingBox.x1 * widthScale,
y1: boundingBox.y1 * heightScale,
x2: boundingBox.x2 * widthScale,
y2: boundingBox.y2 * heightScale,
};
const match = asset.faces.find((face) => this.iou(face, scaledBox) > 0.5);
const matches = asset.faces.filter((face) => this.iou(face, boundingBox, imageWidth, imageHeight) > 0.5);
// prefer metadata faces over machine learning duplicates
const match = matches.find((face) => face.sourceType !== SourceType.MachineLearning) ?? matches[0];
if (match && !mlFaceIds.delete(match.id)) {
embeddings.push({ faceId: match.id, embedding });
@@ -466,17 +460,39 @@ export class PersonService extends BaseService {
}
private iou(
face: { boundingBoxX1: number; boundingBoxY1: number; boundingBoxX2: number; boundingBoxY2: number },
face: {
boundingBoxX1: number;
boundingBoxY1: number;
boundingBoxX2: number;
boundingBoxY2: number;
imageWidth: number;
imageHeight: number;
},
newBox: BoundingBox,
imageWidth: number,
imageHeight: number,
): number {
const x1 = Math.max(face.boundingBoxX1, newBox.x1);
const y1 = Math.max(face.boundingBoxY1, newBox.y1);
const x2 = Math.min(face.boundingBoxX2, newBox.x2);
const y2 = Math.min(face.boundingBoxY2, newBox.y2);
const box1 = {
x1: face.boundingBoxX1 / face.imageWidth,
y1: face.boundingBoxY1 / face.imageHeight,
x2: face.boundingBoxX2 / face.imageWidth,
y2: face.boundingBoxY2 / face.imageHeight,
};
const box2 = {
x1: newBox.x1 / imageWidth,
y1: newBox.y1 / imageHeight,
x2: newBox.x2 / imageWidth,
y2: newBox.y2 / imageHeight,
};
const x1 = Math.max(box1.x1, box2.x1);
const y1 = Math.max(box1.y1, box2.y1);
const x2 = Math.min(box1.x2, box2.x2);
const y2 = Math.min(box1.y2, box2.y2);
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
const area1 = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1);
const area2 = (newBox.x2 - newBox.x1) * (newBox.y2 - newBox.y1);
const area1 = (box1.x2 - box1.x1) * (box1.y2 - box1.y1);
const area2 = (box2.x2 - box2.x1) * (box2.y2 - box2.y1);
const union = area1 + area2 - intersection;
return intersection / union;
@@ -316,6 +316,11 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
@OnEvent({ name: 'AssetOcrExtracted' })
onAssetOcrExtracted({ userId, assetId }: ArgOf<'AssetOcrExtracted'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetOcrExtraction });
}
@OnEvent({ name: 'AssetTag' })
onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged });
@@ -332,7 +337,7 @@ export class WorkflowExecutionService extends BaseService {
}
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
handleAssetTrigger({ workflowId, assetId, trigger }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
@@ -340,7 +345,7 @@ export class WorkflowExecutionService extends BaseService {
case WorkflowType.AssetV1: {
return {
read: async () => {
const asset = await this.workflowRepository.getForAssetV1(assetId);
const asset = await this.workflowRepository.getForAssetV1(assetId, trigger);
return {
data: { asset } as any,
authUserId: asset.ownerId,
+2 -1
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { ShallowDehydrateObject } from 'kysely';
import { Mocked } from 'vitest';
import { VECTOR_EXTENSIONS } from 'src/constants.js';
@@ -439,7 +440,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string; trigger: WorkflowTrigger } }
// Integrity
| { name: JobName.IntegrityUntrackedFilesQueueAll; data?: IIntegrityJob }
+1
View File
@@ -6,6 +6,7 @@ export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
// [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
[WorkflowTrigger.AssetOcrExtraction]: [WorkflowType.AssetV1],
[WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1],
};
@@ -1,9 +1,9 @@
import { OAuthProfile } from 'src/repositories/oauth.repository.js';
import { OAuthRawProfile } from 'src/repositories/oauth.repository.js';
import { OAuthProfileLike } from 'test/factories/types.js';
import { newUuid } from 'test/small.factory.js';
export class OAuthProfileFactory {
private constructor(private value: OAuthProfile) {}
private constructor(private value: OAuthRawProfile) {}
static create(dto: OAuthProfileLike = {}) {
return OAuthProfileFactory.from(dto).build();
+2 -2
View File
@@ -1,7 +1,7 @@
import { Selectable } from 'kysely';
import { PersonUser } from 'src/database.js';
import { PersonUserRole } from 'src/dtos/person.dto.js';
import { OAuthProfile } from 'src/repositories/oauth.repository.js';
import { OAuthRawProfile } from 'src/repositories/oauth.repository.js';
import { ActivityTable } from 'src/schema/tables/activity.table.js';
import { AlbumUserTable } from 'src/schema/tables/album-user.table.js';
import { AlbumTable } from 'src/schema/tables/album.table.js';
@@ -48,4 +48,4 @@ export type ActivityLike = Partial<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type PasskeyLike = Partial<Selectable<PasskeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>;
export type OAuthProfileLike = Partial<OAuthProfile>;
export type OAuthProfileLike = Partial<OAuthRawProfile>;
+23
View File
@@ -78,6 +78,7 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table.js';
import { AssetFileTable } from 'src/schema/tables/asset-file.table.js';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table.js';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table.js';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table.js';
import { AssetTable } from 'src/schema/tables/asset.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
import { MemoryTable } from 'src/schema/tables/memory.table.js';
@@ -230,6 +231,28 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
return { assetFace, result };
}
async newAssetOcr(
dto: (Partial<Insertable<AssetOcrTable>> & { assetId: string; text: string; textScore: number })[],
) {
const result = await this.get(OcrRepository).upsert(
dto[0].assetId,
dto.map((item) => ({
...item,
x1: 0,
y1: 0,
x2: 1,
y2: 1,
x3: 2,
y3: 2,
x4: 3,
y4: 3,
boxScore: item.textScore,
})),
'',
);
return { ocr: dto, result };
}
async newMemory(dto: Partial<Insertable<MemoryTable>> = {}) {
const memory = mediumFactory.memoryInsert(dto);
const result = await this.get(MemoryRepository).create(memory, new Set<string>());
@@ -3,6 +3,7 @@ import { AssetFileType, JobStatus } from 'src/enum.js';
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { EventRepository } from 'src/repositories/event.repository.js';
import { JobRepository } from 'src/repositories/job.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository.js';
@@ -20,7 +21,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(OcrService, {
database: db || defaultDatabase,
real: [AssetRepository, AssetJobRepository, ConfigRepository, OcrRepository, SystemMetadataRepository],
mock: [JobRepository, LoggingRepository, MachineLearningRepository],
mock: [EventRepository, JobRepository, LoggingRepository, MachineLearningRepository],
});
};
@@ -39,6 +40,7 @@ describe(OcrService.name, () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
ctx.getMock(EventRepository).emit.mockResolvedValue();
const machineLearningMock = ctx.getMock(MachineLearningRepository);
machineLearningMock.ocr.mockResolvedValue({
@@ -91,6 +93,7 @@ describe(OcrService.name, () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
ctx.getMock(EventRepository).emit.mockResolvedValue();
const machineLearningMock = ctx.getMock(MachineLearningRepository);
machineLearningMock.ocr.mockResolvedValue({
@@ -215,6 +218,7 @@ describe(OcrService.name, () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
ctx.getMock(EventRepository).emit.mockResolvedValue();
const machineLearningMock = ctx.getMock(MachineLearningRepository);
machineLearningMock.ocr.mockResolvedValue({ box: [], boxScore: [], text: [], textScore: [] });
@@ -240,6 +244,7 @@ describe(OcrService.name, () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
ctx.getMock(EventRepository).emit.mockResolvedValue();
const machineLearningMock = ctx.getMock(MachineLearningRepository);
machineLearningMock.ocr.mockResolvedValue({
@@ -146,7 +146,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive' }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive,
@@ -163,7 +169,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -182,7 +194,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock' }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Locked,
@@ -199,7 +217,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -218,7 +242,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite' }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -233,7 +263,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -250,7 +286,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [], albumName: 'Screenshots' } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
const albums = await ctx.get(AlbumRepository).getAll(user.id);
expect(albums).toHaveLength(1);
@@ -281,7 +323,13 @@ describe('core plugin', () => {
expect(albums).toHaveLength(1);
expect(albums[0].albumName).toEqual(album.albumName);
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
@@ -297,7 +345,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
@@ -316,7 +370,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
@@ -334,7 +394,13 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeTruthy();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
@@ -360,7 +426,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -383,7 +455,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -406,7 +484,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
});
@@ -434,10 +518,18 @@ describe('core plugin', () => {
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
@@ -465,10 +557,18 @@ describe('core plugin', () => {
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false });
@@ -496,10 +596,18 @@ describe('core plugin', () => {
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false });
@@ -525,7 +633,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
});
@@ -552,7 +666,11 @@ describe('core plugin', () => {
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -577,7 +695,11 @@ describe('core plugin', () => {
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -604,7 +726,11 @@ describe('core plugin', () => {
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -629,7 +755,11 @@ describe('core plugin', () => {
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -663,13 +793,25 @@ describe('core plugin', () => {
],
});
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id });
await ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetCreate,
});
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
await ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetCreate,
});
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
await ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset3.id,
trigger: WorkflowTrigger.AssetCreate,
});
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -700,17 +842,157 @@ describe('core plugin', () => {
],
});
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id });
await ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetCreate,
});
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
await ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetCreate,
});
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
await ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset3.id,
trigger: WorkflowTrigger.AssetCreate,
});
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
});
});
describe('assetOcrFilter', () => {
it('should match assets case-insensitively', async () => {
const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id }),
ctx.newAsset({ ownerId: user.id }),
]);
await ctx.newAssetOcr([{ assetId: asset1.id, text: 'test', textScore: 0.9 }]);
await ctx.newAssetOcr([{ assetId: asset2.id, text: 'bogus', textScore: 0.9 }]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
steps: [
{
method: 'immich-plugin-core#assetOcrFilter',
config: { matchType: 'contains', matchScope: 'box', pattern: 'Test', minScore: 0 },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
}),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false });
});
it('should match assets by regex in full text', async () => {
const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id }),
ctx.newAsset({ ownerId: user.id }),
]);
await ctx.newAssetOcr([
{ assetId: asset1.id, text: 'Test', textScore: 0.9 },
{ assetId: asset1.id, text: 'Match', textScore: 0.5 },
]);
await ctx.newAssetOcr([{ assetId: asset2.id, text: 'Test', textScore: 0.9 }]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#assetOcrFilter',
config: { matchType: 'regex', matchScope: 'full', pattern: 'Test.+Match', minScore: 0 },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
}),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false });
});
it('should filter assets with low score', async () => {
const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id }),
ctx.newAsset({ ownerId: user.id }),
]);
await ctx.newAssetOcr([{ assetId: asset1.id, text: 'test', textScore: 0.9 }]);
await ctx.newAssetOcr([{ assetId: asset2.id, text: 'test', textScore: 0.5 }]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
steps: [
{
method: 'immich-plugin-core#assetOcrFilter',
config: { matchType: 'contains', matchScope: 'box', pattern: 'test', minScore: 0.8 },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset1.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
}),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset2.id,
trigger: WorkflowTrigger.AssetOcrExtraction,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false });
});
});
describe('assetTagFilter', () => {
it('should favorite asset if it matches any tag', async () => {
const { user } = await ctx.newUser();
@@ -732,7 +1014,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -755,7 +1043,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -779,7 +1073,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -802,7 +1102,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -826,7 +1132,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
});
@@ -850,7 +1162,13 @@ describe('core plugin', () => {
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalled();
});
@@ -887,7 +1205,13 @@ describe('core plugin', () => {
.execute();
}
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({
workflowId: workflow.id,
assetId: asset.id,
trigger: WorkflowTrigger.AssetCreate,
}),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
});
@@ -29,6 +29,7 @@
import { getAlbumsByAssetQuery } from '#lib/queries/album.query.ts';
import { createPreloadedQuery } from '#lib/queries/client.ts';
import { getPublicConfigQuery } from '#lib/queries/config-public.query.ts';
import { getCameraName } from '#lib/utils/camera-utils.ts';
const query = createPreloadedQuery(getPublicConfigQuery);
const publicConfig = $derived(query.data);
@@ -203,17 +204,17 @@
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
{const cameraName = getCameraName(asset.exifInfo.make, asset.exifInfo.model)}
<p>
<a
href={Route.search({
make: asset.exifInfo?.make ?? undefined,
model: asset.exifInfo?.model ?? undefined,
})}
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
title="{$t('search_for')} {cameraName}"
class="hover:text-primary"
>
{asset.exifInfo.make || ''}
{asset.exifInfo.model || ''}
{cameraName}
</a>
</p>
{/if}
@@ -1,10 +1,9 @@
<script lang="ts">
import MenuOption from '#lib/components/shared-components/context-menu/MenuOption.svelte';
import { assetMultiSelectManager } from '#lib/managers/asset-multi-select-manager.svelte.ts';
import { toggleFavoriteAssets } from '#lib/services/asset.service.ts';
import type { OnFavorite } from '#lib/utils/actions.ts';
import { handleError } from '#lib/utils/handle-error.ts';
import { updateAssets } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { IconButton } from '@immich/ui';
import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -27,28 +26,8 @@
try {
const assets = assetMultiSelectManager.ownedAssets.filter((asset) => asset.isFavorite !== isFavorite);
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
toastManager.primary(
isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
);
await toggleFavoriteAssets(assets, isFavorite, onFavorite);
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
} finally {
loading = false;
}
@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '#lib/actions/shortcut.ts';
import {
setFocusToAsset as setFocusAssetInit,
@@ -14,6 +13,7 @@
import NavigateToDateModal from '#lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '#lib/modals/ShortcutsModal.svelte';
import { Route } from '#lib/route.ts';
import { toggleFavoriteAssets } from '#lib/services/asset.service.ts';
import { handleStack } from '#lib/services/stack.service.ts';
import { keyboardManager } from '#lib/stores/keyboard-manager.svelte.ts';
import { showDeleteModal } from '#lib/stores/preferences.store.ts';
@@ -21,6 +21,7 @@
import { handlePromiseError } from '#lib/utils.ts';
import { deleteAssets } from '#lib/utils/actions.ts';
import { selectAllAssets } from '#lib/utils/asset-utils.ts';
import { goto } from '$app/navigation';
import { isModalOpen, modalManager } from '@immich/ui';
type Props = {
@@ -62,6 +63,14 @@
onEscape?.();
};
const toggleFavoriteSelected = async () => {
const allAssets = assetInteraction.ownedAssets;
const isFavorite = allAssets.some((a) => !a.isFavorite);
const assetsToUpdate = allAssets.filter((asset) => asset.isFavorite !== isFavorite);
await toggleFavoriteAssets(assetsToUpdate, isFavorite);
assetInteraction.clear();
};
const onSelectStart = (e: Event) => {
if (!searchStore.isSearchEnabled && assetInteraction.selectionActive && keyboardManager.shift) {
e.preventDefault();
@@ -125,6 +134,7 @@
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => assetInteraction.clear() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'f' }, onShortcut: () => toggleFavoriteSelected() },
);
} else {
// conflicting shortcuts
+32
View File
@@ -15,6 +15,7 @@ import { Route } from '#lib/route.ts';
import { SlideshowState, slideshowStore } from '#lib/stores/slideshow.store.ts';
import { getAssetMediaUrl, getSharedLink, sleep } from '#lib/utils.ts';
import { downloadUrl } from '#lib/utils.ts';
import type { OnFavorite } from '#lib/utils/actions.ts';
import { handleError } from '#lib/utils/handle-error.ts';
import { getFormatter } from '#lib/utils/i18n.ts';
import {
@@ -63,6 +64,8 @@ import {
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
export const getAssetBulkActions = ($t: MessageFormatter, album?: AlbumResponseDto) => {
@@ -496,6 +499,35 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
}
};
export const toggleFavoriteAssets = async (
assets: Pick<AssetResponseDto, 'id' | 'isFavorite'>[],
isFavorite: boolean,
onFavorite?: OnFavorite,
) => {
const ids = assets.map((a) => a.id);
const $t = get(t);
try {
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
toastManager.primary(
isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
}
};
const handleRate = async (asset: AssetResponseDto, rating: number) => {
const $t = await getFormatter();
+44
View File
@@ -0,0 +1,44 @@
import { getCameraName } from '#lib/utils/camera-utils.ts';
describe('getCameraName', () => {
it('should not repeat the make when the model already starts with it', () => {
expect(getCameraName('Canon', 'Canon EOS R6m2')).toBe('Canon EOS R6m2');
expect(getCameraName('Canon', 'Canon EOS 5D Mark IV')).toBe('Canon EOS 5D Mark IV');
});
it('should compare the make and model case-insensitively', () => {
expect(getCameraName('NIKON', 'Nikon Z 6')).toBe('Nikon Z 6');
expect(getCameraName('canon', 'CANON EOS R5')).toBe('CANON EOS R5');
});
it('should compare against the first word of a multi-word make', () => {
expect(getCameraName('NIKON CORPORATION', 'NIKON D850')).toBe('NIKON D850');
expect(getCameraName('OLYMPUS IMAGING CORP.', 'OLYMPUS E-M1')).toBe('OLYMPUS E-M1');
});
it('should match any word of a multi-word make', () => {
expect(getCameraName('EASTMAN KODAK COMPANY', 'KODAK EASYSHARE C875')).toBe('KODAK EASYSHARE C875');
expect(getCameraName('EASTMAN KODAK COMPANY', 'EASTMAN KODAK 1')).toBe('EASTMAN KODAK 1');
});
it('should prepend the make when the model does not include it', () => {
expect(getCameraName('SONY', 'ILCE-7M4')).toBe('SONY ILCE-7M4');
expect(getCameraName('FUJIFILM', 'X-T5')).toBe('FUJIFILM X-T5');
expect(getCameraName('Apple', 'iPhone 15 Pro')).toBe('Apple iPhone 15 Pro');
});
it('should only treat the make as a prefix on a word boundary', () => {
expect(getCameraName('Canon', 'Canonical X1')).toBe('Canon Canonical X1');
});
it('should handle a missing make or model', () => {
expect(getCameraName('Canon', null)).toBe('Canon');
expect(getCameraName(undefined, 'EOS R6m2')).toBe('EOS R6m2');
expect(getCameraName(null, null)).toBe('');
expect(getCameraName('', '')).toBe('');
});
it('should ignore surrounding whitespace', () => {
expect(getCameraName(' Canon ', ' Canon EOS R6m2 ')).toBe('Canon EOS R6m2');
});
});
+34
View File
@@ -0,0 +1,34 @@
const whitespace = /\s+/;
const isWordChar = (char: string | undefined) => char !== undefined && /\w/.test(char);
/**
Builds the display name of a camera from its EXIF make and model.
Many cameras (e.g. Canon) already include the manufacturer in the model
(`Canon EOS R6m2`), so prefixing the make again would produce "Canon Canon EOS R6m2".
The make is only prepended when the model does not already start with it.
Makes made up of several words are matched word by word, since the model usually
repeats only part of them: `NIKON CORPORATION` / `NIKON D850` and
`EASTMAN KODAK COMPANY` / `KODAK EASYSHARE C875`.
*/
export const getCameraName = (make?: string | null, model?: string | null): string => {
const trimmedMake = make?.trim() ?? '';
const trimmedModel = model?.trim() ?? '';
if (!trimmedMake) {
return trimmedModel;
}
if (!trimmedModel) {
return trimmedMake;
}
const lowerModel = trimmedModel.toLowerCase();
const modelIncludesMake = trimmedMake
.toLowerCase()
.split(whitespace)
.some((word) => lowerModel.startsWith(word) && !isWordChar(lowerModel[word.length]));
return modelIncludesMake ? trimmedModel : `${trimmedMake} ${trimmedModel}`;
};
+6
View File
@@ -13,6 +13,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction');
}
case WorkflowTrigger.AssetOcrExtraction: {
return $t('trigger_asset_ocr_extraction');
}
case WorkflowTrigger.AssetTagged: {
return $t('trigger_asset_tagged');
}
@@ -33,6 +36,9 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction_description');
}
case WorkflowTrigger.AssetOcrExtraction: {
return $t('trigger_asset_ocr_extraction_description');
}
case WorkflowTrigger.AssetTagged: {
return $t('trigger_asset_tagged_description');
}