Compare commits
15
Commits
2c1f321627
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132af2fc74 | ||
|
|
9e5bdda89c | ||
|
|
79ea67f6be | ||
|
|
79b175465e | ||
|
|
c76bd08f73 | ||
|
|
f2e05e9d98 | ||
|
|
c8a957a116 | ||
|
|
f7697264ed | ||
|
|
648dea2072 | ||
|
|
895b755f53 | ||
|
|
568a9d977e | ||
|
|
3c383e46d1 | ||
|
|
477fcc2338 | ||
|
|
ff52f491fb | ||
|
|
c3933169de |
@@ -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!
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
-20
@@ -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
@@ -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),
|
||||
|
||||
+2
-6
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -34673,6 +34673,7 @@
|
||||
"enum": [
|
||||
"AssetCreate",
|
||||
"AssetMetadataExtraction",
|
||||
"AssetOcrExtraction",
|
||||
"AssetTagged"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}[]
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -8603,6 +8603,7 @@ export enum WorkflowType {
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = "AssetCreate",
|
||||
AssetMetadataExtraction = "AssetMetadataExtraction",
|
||||
AssetOcrExtraction = "AssetOcrExtraction",
|
||||
AssetTagged = "AssetTagged"
|
||||
}
|
||||
export enum QueueJobStatus {
|
||||
|
||||
Generated
+29
-5
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -115,16 +115,6 @@
|
||||
|
||||

|
||||
|
||||
## История на звездите
|
||||
|
||||
<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">
|
||||
|
||||
@@ -118,16 +118,6 @@ Mehr zum Thema Übersetzungen kannst du [hier](https://docs.immich.app/developer
|
||||
|
||||

|
||||
|
||||
## 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">
|
||||
|
||||
@@ -110,16 +110,6 @@ Lea mas acerca de las traducciones [acá](https://docs.immich.app/developer/tran
|
||||
|
||||

|
||||
|
||||
## 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -117,16 +117,6 @@ Scopri di più sulle traduzioni [qui](https://docs.immich.app/developer/translat
|
||||
|
||||

|
||||
|
||||
## 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">
|
||||
|
||||
@@ -115,16 +115,6 @@
|
||||
|
||||

|
||||
|
||||
## 스타 기록
|
||||
|
||||
<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">
|
||||
|
||||
@@ -117,16 +117,6 @@
|
||||
|
||||

|
||||
|
||||
## സ്റ്റാർ ചരിത്രം
|
||||
|
||||
<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">
|
||||
|
||||
@@ -109,16 +109,6 @@ Je kunt [hier](https://docs.immich.app/developer/translations) meer over vertali
|
||||
|
||||

|
||||
|
||||
## 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">
|
||||
|
||||
@@ -122,16 +122,6 @@ Leia mais sobre as traduções
|
||||
|
||||

|
||||
|
||||
## 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">
|
||||
|
||||
@@ -117,16 +117,6 @@
|
||||
|
||||

|
||||
|
||||
## История звёзд
|
||||
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -119,16 +119,6 @@
|
||||
|
||||

|
||||
|
||||
## ประวัติการให้ดาว
|
||||
|
||||
<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">
|
||||
|
||||
@@ -117,16 +117,6 @@
|
||||
|
||||

|
||||
|
||||
## Історія зірок
|
||||
|
||||
<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">
|
||||
|
||||
@@ -117,16 +117,6 @@ Truy cập bản demo [tại đây](https://demo.immich.app). Đối với ứng
|
||||
|
||||

|
||||
|
||||
## 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">
|
||||
|
||||
@@ -118,16 +118,6 @@
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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,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
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user