Compare commits
18 Commits
feat/mobil
...
v1.143.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee6bcc5ef | ||
|
|
b2f3bf7079 | ||
|
|
fe416b121c | ||
|
|
35b62cd016 | ||
|
|
b33e8abcdd | ||
|
|
0be71c82b3 | ||
|
|
a582d3a03e | ||
|
|
6609e70fa8 | ||
|
|
7a0107fc79 | ||
|
|
0bbeb20595 | ||
|
|
afc4085b55 | ||
|
|
02569d52f0 | ||
|
|
aaeac2ab73 | ||
|
|
de57fecb69 | ||
|
|
1e0b4fac04 | ||
|
|
34339ea69f | ||
|
|
6da039780e | ||
|
|
3f2e0780d5 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.90",
|
||||
"version": "2.2.91",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
@@ -169,8 +169,6 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
||||
| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
|
||||
| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
|
||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
|
||||
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "v1.143.0",
|
||||
"url": "https://v1.143.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.142.1",
|
||||
"url": "https://v1.142.1.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.142.1",
|
||||
"version": "1.143.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
10
i18n/en.json
10
i18n/en.json
@@ -123,6 +123,13 @@
|
||||
"logging_enable_description": "Enable logging",
|
||||
"logging_level_description": "When enabled, what log level to use.",
|
||||
"logging_settings": "Logging",
|
||||
"machine_learning_availability_checks": "Availability checks",
|
||||
"machine_learning_availability_checks_description": "Automatically detect and prefer available machine learning servers",
|
||||
"machine_learning_availability_checks_enabled": "Enable availability checks",
|
||||
"machine_learning_availability_checks_interval": "Check interval",
|
||||
"machine_learning_availability_checks_interval_description": "Interval in milliseconds between availability checks",
|
||||
"machine_learning_availability_checks_timeout": "Request timeout",
|
||||
"machine_learning_availability_checks_timeout_description": "Timeout in milliseconds for availability checks",
|
||||
"machine_learning_clip_model": "CLIP model",
|
||||
"machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
|
||||
"machine_learning_duplicate_detection": "Duplicate Detection",
|
||||
@@ -1520,6 +1527,7 @@
|
||||
"port": "Port",
|
||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||
"preferences_settings_title": "Preferences",
|
||||
"preparing": "Preparing",
|
||||
"preset": "Preset",
|
||||
"preview": "Preview",
|
||||
"previous": "Previous",
|
||||
@@ -1585,6 +1593,7 @@
|
||||
"read_changelog": "Read Changelog",
|
||||
"readonly_mode_disabled": "Read-only mode disabled",
|
||||
"readonly_mode_enabled": "Read-only mode enabled",
|
||||
"ready_for_upload": "Ready for upload",
|
||||
"reassign": "Reassign",
|
||||
"reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}",
|
||||
"reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person",
|
||||
@@ -1916,6 +1925,7 @@
|
||||
"stacktrace": "Stacktrace",
|
||||
"start": "Start",
|
||||
"start_date": "Start date",
|
||||
"start_date_before_end_date": "Start date must be before end date",
|
||||
"state": "State",
|
||||
"status": "Status",
|
||||
"stop_casting": "Stop casting",
|
||||
|
||||
138
machine-learning/uv.lock
generated
138
machine-learning/uv.lock
generated
@@ -507,61 +507,87 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.6.4"
|
||||
version = "7.10.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716, upload-time = "2024-10-20T22:57:39.682Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/93/4ad92f71e28ece5c0326e5f4a6630aa4928a8846654a65cfff69b49b95b9/coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07", size = 206713, upload-time = "2024-10-20T22:56:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/ae/747a580b1eda3f2e431d87de48f0604bd7bc92e52a1a95185a4aa585bc47/coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0", size = 207149, upload-time = "2024-10-20T22:56:06.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/1a/1f573f8a6145f6d4c9130bbc120e0024daf1b24cf2a78d7393fa6eb6aba7/coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72", size = 235584, upload-time = "2024-10-20T22:56:07.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/42/c8523f2e4db34aa9389caee0d3688b6ada7a84fcc782e943a868a7f302bd/coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51", size = 233486, upload-time = "2024-10-20T22:56:09.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/95/565c310fffa16ede1a042e9ea1ca3962af0d8eb5543bc72df6b91dc0c3d5/coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491", size = 234649, upload-time = "2024-10-20T22:56:11.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/81/3b550674d98968ec29c92e3e8650682be6c8b1fa7581a059e7e12e74c431/coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b", size = 233744, upload-time = "2024-10-20T22:56:12.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/70/d66c7f51b3e33aabc5ea9f9624c1c9d9655472962270eb5e7b0d32707224/coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea", size = 232204, upload-time = "2024-10-20T22:56:14.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/2d/2b3a2dbed7a5f40693404c8a09e779d7c1a5fbed089d3e7224c002129ec8/coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a", size = 233335, upload-time = "2024-10-20T22:56:15.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/4f/92d1d2ad720d698a4e71c176eacf531bfb8e0721d5ad560556f2c484a513/coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa", size = 209435, upload-time = "2024-10-20T22:56:17.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b9/cdf158e7991e2287bcf9082670928badb73d310047facac203ff8dcd5ff3/coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172", size = 210243, upload-time = "2024-10-20T22:56:18.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/31/9c0cf84f0dfcbe4215b7eb95c31777cdc0483c13390e69584c8150c85175/coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", size = 206819, upload-time = "2024-10-20T22:56:20.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/ed/a38401079ad320ad6e054a01ec2b61d270511aeb3c201c80e99c841229d5/coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", size = 207263, upload-time = "2024-10-20T22:56:21.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e7/c3ad33b179ab4213f0d70da25a9c214d52464efa11caeab438592eb1d837/coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", size = 239205, upload-time = "2024-10-20T22:56:23.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/91/fc02e8d8e694f557752120487fd982f654ba1421bbaa5560debf96ddceda/coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", size = 236612, upload-time = "2024-10-20T22:56:24.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/57/cb08f0eda0389a9a8aaa4fc1f9fec7ac361c3e2d68efd5890d7042c18aa3/coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", size = 238479, upload-time = "2024-10-20T22:56:26.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/c9/2c7681a9b3ca6e6f43d489c2e6653a53278ed857fd6e7010490c307b0a47/coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", size = 237405, upload-time = "2024-10-20T22:56:27.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/4e/ebfc6944b96317df8b537ae875d2e57c27b84eb98820bc0a1055f358f056/coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", size = 236038, upload-time = "2024-10-20T22:56:29.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f2/3a0bf1841a97c0654905e2ef531170f02c89fad2555879db8fe41a097871/coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", size = 236812, upload-time = "2024-10-20T22:56:31.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9c/66bf59226b52ce6ed9541b02d33e80a6e816a832558fbdc1111a7bd3abd4/coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", size = 209400, upload-time = "2024-10-20T22:56:33.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a0/b0790934c04dfc8d658d4a62acb8f7ca0efdf3818456fcad757b11c6479d/coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", size = 210243, upload-time = "2024-10-20T22:56:34.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e7/9291de916d084f41adddfd4b82246e68d61d6a75747f075f7e64628998d2/coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", size = 207013, upload-time = "2024-10-20T22:56:36.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/03/932c2c5717a7fa80cd43c6a07d3177076d97b79f12f40f882f9916db0063/coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", size = 207251, upload-time = "2024-10-20T22:56:38.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/3f/0af47dcb9327f65a45455fbca846fe96eb57c153af46c4754a3ba678938a/coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", size = 240268, upload-time = "2024-10-20T22:56:40.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/3c/37a9d81bbd4b23bc7d46ca820e16174c613579c66342faa390a271d2e18b/coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", size = 237298, upload-time = "2024-10-20T22:56:41.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/70/6b0627e5bd68204ee580126ed3513140b2298995c1233bd67404b4e44d0e/coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", size = 239367, upload-time = "2024-10-20T22:56:43.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/eb/634d7dfab24ac3b790bebaf9da0f4a5352cbc125ce6a9d5c6cf4c6cae3c7/coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", size = 238853, upload-time = "2024-10-20T22:56:44.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/0d/8e3ed00f1266ef7472a4e33458f42e39492e01a64281084fb3043553d3f1/coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", size = 237160, upload-time = "2024-10-20T22:56:46.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9c/4337f468ef0ab7a2e0887a9c9da0e58e2eada6fc6cbee637a4acd5dfd8a9/coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", size = 238824, upload-time = "2024-10-20T22:56:48.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/09/3e94912b8dd37251377bb02727a33a67ee96b84bbbe092f132b401ca5dd9/coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", size = 209639, upload-time = "2024-10-20T22:56:50.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/69/d4f3a4101171f32bc5b3caec8ff94c2c60f700107a6aaef7244b2c166793/coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", size = 210428, upload-time = "2024-10-20T22:56:52.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039, upload-time = "2024-10-20T22:56:53.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298, upload-time = "2024-10-20T22:56:54.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813, upload-time = "2024-10-20T22:56:56.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959, upload-time = "2024-10-20T22:56:58.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950, upload-time = "2024-10-20T22:56:59.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610, upload-time = "2024-10-20T22:57:00.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697, upload-time = "2024-10-20T22:57:01.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541, upload-time = "2024-10-20T22:57:03.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707, upload-time = "2024-10-20T22:57:05.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439, upload-time = "2024-10-20T22:57:06.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784, upload-time = "2024-10-20T22:57:07.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058, upload-time = "2024-10-20T22:57:09.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772, upload-time = "2024-10-20T22:57:11.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490, upload-time = "2024-10-20T22:57:13.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848, upload-time = "2024-10-20T22:57:14.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340, upload-time = "2024-10-20T22:57:16.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229, upload-time = "2024-10-20T22:57:17.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510, upload-time = "2024-10-20T22:57:18.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353, upload-time = "2024-10-20T22:57:20.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502, upload-time = "2024-10-20T22:57:22.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/56/e1d75e8981a2a92c2a777e67c26efa96c66da59d645423146eb9ff3a851b/coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e", size = 198954, upload-time = "2024-10-20T22:57:38.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/1d/2e64b43d978b5bd184e0756a41415597dfef30fcbd90b747474bd749d45f/coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356", size = 217025, upload-time = "2025-08-29T15:32:57.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/62/b1e0f513417c02cc10ef735c3ee5186df55f190f70498b3702d516aad06f/coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301", size = 217419, upload-time = "2025-08-29T15:32:59.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/16/b800640b7a43e7c538429e4d7223e0a94fd72453a1a048f70bf766f12e96/coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460", size = 244180, upload-time = "2025-08-29T15:33:01.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/6f/5e03631c3305cad187eaf76af0b559fff88af9a0b0c180d006fb02413d7a/coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd", size = 245992, upload-time = "2025-08-29T15:33:03.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a1/f30ea0fb400b080730125b490771ec62b3375789f90af0bb68bfb8a921d7/coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb", size = 247851, upload-time = "2025-08-29T15:33:04.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8e/cfa8fee8e8ef9a6bb76c7bef039f3302f44e615d2194161a21d3d83ac2e9/coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6", size = 245891, upload-time = "2025-08-29T15:33:06.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/a9/51be09b75c55c4f6c16d8d73a6a1d46ad764acca0eab48fa2ffaef5958fe/coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945", size = 243909, upload-time = "2025-08-29T15:33:07.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a6/ba188b376529ce36483b2d585ca7bdac64aacbe5aa10da5978029a9c94db/coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e", size = 244786, upload-time = "2025-08-29T15:33:08.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/4c/37ed872374a21813e0d3215256180c9a382c3f5ced6f2e5da0102fc2fd3e/coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1", size = 219521, upload-time = "2025-08-29T15:33:10.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/36/9311352fdc551dec5b973b61f4e453227ce482985a9368305880af4f85dd/coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528", size = 220417, upload-time = "2025-08-29T15:33:11.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/16/2bea27e212c4980753d6d563a0803c150edeaaddb0771a50d2afc410a261/coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f", size = 217129, upload-time = "2025-08-29T15:33:13.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/51/e7159e068831ab37e31aac0969d47b8c5ee25b7d307b51e310ec34869315/coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc", size = 217532, upload-time = "2025-08-29T15:33:14.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c0/246ccbea53d6099325d25cd208df94ea435cd55f0db38099dd721efc7a1f/coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a", size = 247931, upload-time = "2025-08-29T15:33:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/fb/7435ef8ab9b2594a6e3f58505cc30e98ae8b33265d844007737946c59389/coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a", size = 249864, upload-time = "2025-08-29T15:33:17.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/f8/d9d64e8da7bcddb094d511154824038833c81e3a039020a9d6539bf303e9/coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62", size = 251969, upload-time = "2025-08-29T15:33:18.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/28/c43ba0ef19f446d6463c751315140d8f2a521e04c3e79e5c5fe211bfa430/coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153", size = 249659, upload-time = "2025-08-29T15:33:20.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3e/53635bd0b72beaacf265784508a0b386defc9ab7fad99ff95f79ce9db555/coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5", size = 247714, upload-time = "2025-08-29T15:33:21.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/55/0964aa87126624e8c159e32b0bc4e84edef78c89a1a4b924d28dd8265625/coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619", size = 248351, upload-time = "2025-08-29T15:33:23.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ab/6cfa9dc518c6c8e14a691c54e53a9433ba67336c760607e299bfcf520cb1/coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba", size = 219562, upload-time = "2025-08-29T15:33:24.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/18/99b25346690cbc55922e7cfef06d755d4abee803ef335baff0014268eff4/coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e", size = 220453, upload-time = "2025-08-29T15:33:26.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ed/81d86648a07ccb124a5cf1f1a7788712b8d7216b593562683cd5c9b0d2c1/coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c", size = 219127, upload-time = "2025-08-29T15:33:27.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/06/263f3305c97ad78aab066d116b52250dd316e74fcc20c197b61e07eb391a/coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea", size = 217324, upload-time = "2025-08-29T15:33:29.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/60/1e1ded9a4fe80d843d7d53b3e395c1db3ff32d6c301e501f393b2e6c1c1f/coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634", size = 217560, upload-time = "2025-08-29T15:33:30.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/25/52136173c14e26dfed8b106ed725811bb53c30b896d04d28d74cb64318b3/coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6", size = 249053, upload-time = "2025-08-29T15:33:32.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1d/ae25a7dc58fcce8b172d42ffe5313fc267afe61c97fa872b80ee72d9515a/coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9", size = 251802, upload-time = "2025-08-29T15:33:33.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/1f561d47743710fe996957ed7c124b421320f150f1d38523d8d9102d3e2a/coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c", size = 252935, upload-time = "2025-08-29T15:33:34.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/ad/8b97cd5d28aecdfde792dcbf646bac141167a5cacae2cd775998b45fabb5/coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a", size = 250855, upload-time = "2025-08-29T15:33:36.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6a/95c32b558d9a61858ff9d79580d3877df3eb5bc9eed0941b1f187c89e143/coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5", size = 248974, upload-time = "2025-08-29T15:33:38.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9c/8ce95dee640a38e760d5b747c10913e7a06554704d60b41e73fdea6a1ffd/coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972", size = 250409, upload-time = "2025-08-29T15:33:39.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/12/7a55b0bdde78a98e2eb2356771fd2dcddb96579e8342bb52aa5bc52e96f0/coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", size = 219724, upload-time = "2025-08-29T15:33:41.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/4a/32b185b8b8e327802c9efce3d3108d2fe2d9d31f153a0f7ecfd59c773705/coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629", size = 220536, upload-time = "2025-08-29T15:33:42.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/3a/d5d8dc703e4998038c3099eaf77adddb00536a3cec08c8dcd556a36a3eb4/coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", size = 219171, upload-time = "2025-08-29T15:33:43.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2236,16 +2262,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.2.1"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
202
mise.toml
202
mise.toml
@@ -1,11 +1,10 @@
|
||||
[tools]
|
||||
node = "22.19.0"
|
||||
flutter = "3.35.4"
|
||||
pnpm = "10.14.0"
|
||||
dart = "3.8.2"
|
||||
pnpm = "10.15.1"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.31.4"
|
||||
version = "1.30.0"
|
||||
bin = "dcm"
|
||||
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||
|
||||
@@ -309,3 +308,200 @@ run = [
|
||||
"mise run web:test --run",
|
||||
"mise run web:lint",
|
||||
]
|
||||
|
||||
|
||||
# mobile
|
||||
[tasks."mobile:codegen:dart"]
|
||||
alias = "mobile:codegen"
|
||||
description = "Execute build_runner to auto-generate dart code"
|
||||
dir = "mobile"
|
||||
sources = ["pubspec.yaml", "build.yaml", "lib/**/*.dart"]
|
||||
outputs = { auto = true }
|
||||
run = "dart run build_runner build --delete-conflicting-outputs"
|
||||
|
||||
[tasks."mobile:codegen:pigeon"]
|
||||
alias = "mobile:pigeon"
|
||||
description = "Generate pigeon platform code"
|
||||
dir = "mobile"
|
||||
depends = [
|
||||
"mobile:pigeon:native-sync",
|
||||
"mobile:pigeon:thumbnail",
|
||||
"mobile:pigeon:background-worker",
|
||||
"mobile:pigeon:background-worker-lock",
|
||||
"mobile:pigeon:connectivity",
|
||||
]
|
||||
|
||||
[tasks."mobile:codegen:translation"]
|
||||
alias = "mobile:translation"
|
||||
description = "Generate translations from i18n JSONs"
|
||||
dir = "mobile"
|
||||
run = [
|
||||
{ task = "i18n:format-fix" },
|
||||
{ tasks = [
|
||||
"mobile:i18n:loader",
|
||||
"mobile:i18n:keys",
|
||||
] },
|
||||
]
|
||||
|
||||
[tasks."mobile:codegen:app-icon"]
|
||||
description = "Generate app icons"
|
||||
dir = "mobile"
|
||||
run = "flutter pub run flutter_launcher_icons:main"
|
||||
|
||||
[tasks."mobile:codegen:splash"]
|
||||
description = "Generate splash screen"
|
||||
dir = "mobile"
|
||||
run = "flutter pub run flutter_native_splash:create"
|
||||
|
||||
[tasks."mobile:test"]
|
||||
description = "Run mobile tests"
|
||||
dir = "mobile"
|
||||
run = "flutter test"
|
||||
|
||||
[tasks."mobile:lint"]
|
||||
description = "Analyze Dart code"
|
||||
dir = "mobile"
|
||||
depends = ["mobile:analyze:dart", "mobile:analyze:dcm"]
|
||||
|
||||
[tasks."mobile:lint-fix"]
|
||||
description = "Auto-fix Dart code"
|
||||
dir = "mobile"
|
||||
depends = ["mobile:analyze:fix:dart", "mobile:analyze:fix:dcm"]
|
||||
|
||||
[tasks."mobile:format"]
|
||||
description = "Format Dart code"
|
||||
dir = "mobile"
|
||||
run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))"
|
||||
|
||||
[tasks."mobile:build:android"]
|
||||
description = "Build Android release"
|
||||
dir = "mobile"
|
||||
run = "flutter build appbundle"
|
||||
|
||||
[tasks."mobile:drift:migration"]
|
||||
alias = "mobile:migration"
|
||||
description = "Generate database migrations"
|
||||
dir = "mobile"
|
||||
run = "dart run drift_dev make-migrations"
|
||||
|
||||
|
||||
# mobile internal tasks
|
||||
[tasks."mobile:pigeon:native-sync"]
|
||||
description = "Generate native sync API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/native_sync_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/native_sync_api.g.dart",
|
||||
"ios/Runner/Sync/Messages.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/native_sync_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:thumbnail"]
|
||||
description = "Generate thumbnail API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/thumbnail_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/thumbnail_api.g.dart",
|
||||
"ios/Runner/Images/Thumbnails.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/thumbnail_api.dart",
|
||||
"dart format lib/platform/thumbnail_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:background-worker"]
|
||||
description = "Generate background worker API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/background_worker_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/background_worker_api.g.dart",
|
||||
"ios/Runner/Background/BackgroundWorker.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/background_worker_api.dart",
|
||||
"dart format lib/platform/background_worker_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:background-worker-lock"]
|
||||
description = "Generate background worker lock API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/background_worker_lock_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/background_worker_lock_api.g.dart",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||
"dart format lib/platform/background_worker_lock_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:pigeon:connectivity"]
|
||||
description = "Generate connectivity API pigeon code"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["pigeon/connectivity_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/connectivity_api.g.dart",
|
||||
"ios/Runner/Connectivity/Connectivity.g.swift",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||
"dart format lib/platform/connectivity_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:i18n:loader"]
|
||||
description = "Generate i18n loader"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["i18n/"]
|
||||
outputs = "lib/generated/codegen_loader.g.dart"
|
||||
run = [
|
||||
"dart run easy_localization:generate -S ../i18n",
|
||||
"dart format lib/generated/codegen_loader.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:i18n:keys"]
|
||||
description = "Generate i18n keys"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
sources = ["i18n/en.json"]
|
||||
outputs = "lib/generated/intl_keys.g.dart"
|
||||
run = [
|
||||
"dart run bin/generate_keys.dart",
|
||||
"dart format lib/generated/intl_keys.g.dart",
|
||||
]
|
||||
|
||||
[tasks."mobile:analyze:dart"]
|
||||
description = "Run Dart analysis"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dart analyze --fatal-infos"
|
||||
|
||||
[tasks."mobile:analyze:dcm"]
|
||||
description = "Run Dart Code Metrics"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dcm analyze lib --fatal-style --fatal-warnings"
|
||||
|
||||
[tasks."mobile:analyze:fix:dart"]
|
||||
description = "Auto-fix Dart analysis"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dart fix --apply"
|
||||
|
||||
[tasks."mobile:analyze:fix:dcm"]
|
||||
description = "Auto-fix Dart Code Metrics"
|
||||
dir = "mobile"
|
||||
hide = true
|
||||
run = "dcm fix lib"
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.ext.SdkExtensions
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||
import app.alextran.immich.background.BackgroundWorkerLockApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||
import app.alextran.immich.images.ThumbnailApi
|
||||
@@ -24,11 +25,9 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
companion object {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
flutterEngine.plugins.add(BackgroundEngineLock())
|
||||
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
|
||||
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
|
||||
val nativeSyncApiImpl =
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
|
||||
NativeSyncApiImpl26(ctx)
|
||||
@@ -39,6 +38,10 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,50 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.WorkManager
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
private const val TAG = "BackgroundEngineLock"
|
||||
|
||||
class BackgroundEngineLock : FlutterPlugin {
|
||||
companion object {
|
||||
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||
var engineCount = AtomicInteger(0)
|
||||
}
|
||||
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
// work manager task is running while the main app is opened, cancel the worker
|
||||
if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance()
|
||||
.get(ENGINE_CACHE_KEY) != null
|
||||
) {
|
||||
WorkManager.getInstance(binding.applicationContext)
|
||||
.cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME)
|
||||
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
|
||||
companion object {
|
||||
|
||||
private var engineCount = AtomicInteger(0)
|
||||
|
||||
private fun checkAndEnforceBackgroundLock(ctx: Context) {
|
||||
// work manager task is running while the main app is opened, cancel the worker
|
||||
if (BackgroundWorkerPreferences(ctx).isLocked() &&
|
||||
engineCount.get() > 1 &&
|
||||
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
|
||||
) {
|
||||
Log.i(TAG, "Background worker is locked, cancelling the background worker")
|
||||
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
engineCount.decrementAndGet()
|
||||
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||
}
|
||||
override fun lock() {
|
||||
BackgroundWorkerPreferences(ctx).setLocked(true)
|
||||
checkAndEnforceBackgroundLock(ctx)
|
||||
Log.i(TAG, "Background worker is locked")
|
||||
}
|
||||
|
||||
override fun unlock() {
|
||||
BackgroundWorkerPreferences(ctx).setLocked(false)
|
||||
Log.i(TAG, "Background worker is unlocked")
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
checkAndEnforceBackgroundLock(binding.applicationContext)
|
||||
engineCount.incrementAndGet()
|
||||
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
engineCount.decrementAndGet()
|
||||
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
|
||||
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||
engine = FlutterEngine(ctx)
|
||||
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||
FlutterEngineCache.getInstance()
|
||||
.put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
|
||||
FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!)
|
||||
|
||||
// Register custom plugins
|
||||
MainActivity.registerPlugins(ctx, engine!!)
|
||||
@@ -192,9 +190,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
||||
isComplete = true
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
|
||||
flutterApi = null
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
FlutterEngineCache.getInstance().remove(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY)
|
||||
waitForForegroundPromotion()
|
||||
completionHandler.set(success)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.work.BackoffPolicy
|
||||
@@ -9,6 +8,7 @@ import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val TAG = "BackgroundWorkerApiImpl"
|
||||
@@ -34,8 +34,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
|
||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||
|
||||
|
||||
fun enqueueMediaObserver(ctx: Context) {
|
||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||
@@ -73,35 +75,18 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||
|
||||
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackgroundWorkerPreferences(private val ctx: Context) {
|
||||
companion object {
|
||||
private const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
|
||||
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
|
||||
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
|
||||
fun isBackgroundWorkerRunning(): Boolean {
|
||||
// Easier to check if the engine is cached as we always cache the engine when starting the worker
|
||||
// and remove it when the worker is finished
|
||||
return FlutterEngineCache.getInstance().get(ENGINE_CACHE_KEY) != null
|
||||
}
|
||||
|
||||
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
|
||||
private const val DEFAULT_REQUIRE_CHARGING = false
|
||||
}
|
||||
fun cancelBackgroundWorker(ctx: Context) {
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
|
||||
|
||||
private val sp: SharedPreferences by lazy {
|
||||
ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun updateSettings(settings: BackgroundWorkerSettings) {
|
||||
sp.edit().apply {
|
||||
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
|
||||
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
|
||||
apply()
|
||||
Log.i(TAG, "Cancelled background upload task")
|
||||
}
|
||||
}
|
||||
|
||||
fun getSettings(): BackgroundWorkerSettings {
|
||||
return BackgroundWorkerSettings(
|
||||
minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
|
||||
requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object BackgroundWorkerLockPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class BackgroundWorkerLockPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface BackgroundWorkerLockApi {
|
||||
fun lock()
|
||||
fun unlock()
|
||||
|
||||
companion object {
|
||||
/** The codec used by BackgroundWorkerLockApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
BackgroundWorkerLockPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `BackgroundWorkerLockApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerLockApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.lock()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.unlock()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.alextran.immich.background
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
class BackgroundWorkerPreferences(private val ctx: Context) {
|
||||
companion object {
|
||||
const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
|
||||
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
|
||||
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
|
||||
private const val SHARED_PREF_LOCK_KEY = "BackgroundWorker::isLocked"
|
||||
|
||||
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
|
||||
private const val DEFAULT_REQUIRE_CHARGING = false
|
||||
}
|
||||
|
||||
private val sp: SharedPreferences by lazy {
|
||||
ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun updateSettings(settings: BackgroundWorkerSettings) {
|
||||
sp.edit {
|
||||
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
|
||||
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSettings(): BackgroundWorkerSettings {
|
||||
return BackgroundWorkerSettings(
|
||||
minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
|
||||
requiresCharging = sp.getBoolean(
|
||||
SHARED_PREF_REQUIRE_CHARGING_KEY,
|
||||
DEFAULT_REQUIRE_CHARGING
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun setLocked(paused: Boolean) {
|
||||
sp.edit {
|
||||
putBoolean(SHARED_PREF_LOCK_KEY, paused)
|
||||
}
|
||||
}
|
||||
|
||||
fun isLocked(): Boolean {
|
||||
return sp.getBoolean(SHARED_PREF_LOCK_KEY, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,21 @@ package app.alextran.immich.images
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore.Images
|
||||
import android.provider.MediaStore.Video
|
||||
import android.system.Int64Ref
|
||||
import android.util.Size
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.*
|
||||
import java.util.concurrent.Executors
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -125,15 +121,14 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
signal: CancellationSignal
|
||||
) {
|
||||
signal.throwIfCanceled()
|
||||
val targetWidth = width.toInt()
|
||||
val targetHeight = height.toInt()
|
||||
val size = Size(width.toInt(), height.toInt())
|
||||
val id = assetId.toLong()
|
||||
|
||||
signal.throwIfCanceled()
|
||||
val bitmap = if (isVideo) {
|
||||
decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
|
||||
decodeVideoThumbnail(id, size, signal)
|
||||
} else {
|
||||
decodeImage(id, targetWidth, targetHeight, signal)
|
||||
decodeImage(id, size, signal)
|
||||
}
|
||||
|
||||
processBitmap(bitmap, callback, signal)
|
||||
@@ -156,9 +151,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
bitmap.recycle()
|
||||
signal.throwIfCanceled()
|
||||
val res = mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to actualWidth.toLong(),
|
||||
"height" to actualHeight.toLong()
|
||||
"pointer" to pointer, "width" to actualWidth.toLong(), "height" to actualHeight.toLong()
|
||||
)
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
@@ -167,125 +160,56 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeImage(
|
||||
id: Long, targetWidth: Int, targetHeight: Int, signal: CancellationSignal
|
||||
): Bitmap {
|
||||
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
if (targetHeight > 768 || targetWidth > 768) {
|
||||
return decodeSource(uri, targetWidth, targetHeight, signal)
|
||||
if (size.width <= 0 || size.height <= 0 || size.width > 768 || size.height > 768) {
|
||||
return decodeSource(uri, size, signal)
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
||||
resolver.loadThumbnail(uri, size, signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeVideoThumbnail(
|
||||
id: Long, targetWidth: Int, targetHeight: Int, signal: CancellationSignal
|
||||
): Bitmap {
|
||||
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
||||
// ensure a valid resolution as the thumbnail is used for videos even when no scaling is needed
|
||||
val size = if (target.width > 0 && target.height > 0) target else Size(768, 768)
|
||||
resolver.loadThumbnail(uri, size, signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeSource(
|
||||
uri: Uri, targetWidth: Int, targetHeight: Int, signal: CancellationSignal
|
||||
): Bitmap {
|
||||
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val source = ImageDecoder.createSource(resolver, uri)
|
||||
signal.throwIfCanceled()
|
||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||
if (targetWidth > 0 && targetHeight > 0) {
|
||||
val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
|
||||
if (target.width > 0 && target.height > 0) {
|
||||
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
|
||||
decoder.setTargetSampleSize(sample)
|
||||
}
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
}
|
||||
} else {
|
||||
val ref = Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri)
|
||||
.disallowHardwareConfig().format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.submit(targetWidth, targetHeight)
|
||||
val ref =
|
||||
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888).submit(
|
||||
if (target.width > 0) target.width else SIZE_ORIGINAL,
|
||||
if (target.height > 0) target.height else SIZE_ORIGINAL,
|
||||
)
|
||||
signal.setOnCancelListener { Glide.with(ctx).clear(ref) }
|
||||
ref.get()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Copyright (C) 2006 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun loadThumbnail(uri: Uri, size: Size, signal: CancellationSignal?): Bitmap {
|
||||
// Convert to Point, since that's what the API is defined as
|
||||
val opts = Bundle()
|
||||
if (size.width < 512 && size.height < 512) {
|
||||
opts.putParcelable(ContentResolver.EXTRA_SIZE, Point(size.width, size.height))
|
||||
}
|
||||
val orientation = Int64Ref(0)
|
||||
|
||||
var bitmap =
|
||||
ImageDecoder.decodeBitmap(
|
||||
ImageDecoder.createSource {
|
||||
val afd =
|
||||
resolver.openTypedAssetFile(uri, "image/*", opts, signal)
|
||||
?: throw Resources.NotFoundException("Asset $uri not found")
|
||||
val extras = afd.extras
|
||||
orientation.value =
|
||||
(extras?.getInt(DocumentsContract.EXTRA_ORIENTATION, 0) ?: 0).toLong()
|
||||
afd
|
||||
}
|
||||
) { decoder: ImageDecoder, info: ImageDecoder.ImageInfo, _: ImageDecoder.Source ->
|
||||
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE)
|
||||
// One last-ditch check to see if we've been canceled.
|
||||
signal?.throwIfCanceled()
|
||||
|
||||
// We requested a rough thumbnail size, but the remote size may have
|
||||
// returned something giant, so defensively scale down as needed.
|
||||
// This is modified from the original to target the smaller edge instead of the larger edge.
|
||||
val widthSample = info.size.width.toDouble() / size.width
|
||||
val heightSample = info.size.height.toDouble() / size.height
|
||||
val sample = min(widthSample, heightSample)
|
||||
if (sample > 1) {
|
||||
val width = (info.size.width / sample).toInt()
|
||||
val height = (info.size.height / sample).toInt()
|
||||
decoder.setTargetSize(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
// Transform the bitmap if requested. We use a side-channel to
|
||||
// communicate the orientation, since EXIF thumbnails don't contain
|
||||
// the rotation flags of the original image.
|
||||
if (orientation.value != 0L) {
|
||||
val width = bitmap.getWidth()
|
||||
val height = bitmap.getHeight()
|
||||
|
||||
val m = Matrix()
|
||||
m.setRotate(orientation.value.toFloat(), (width / 2).toFloat(), (height / 2).toFloat())
|
||||
bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false)
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3015,
|
||||
"android.injected.version.name" => "1.142.1",
|
||||
"android.injected.version.code" => 3016,
|
||||
"android.injected.version.name" => "1.143.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -29,11 +29,9 @@
|
||||
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
||||
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
||||
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340C92E7326630050078A /* AssetResolver.swift */; };
|
||||
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CB2E7326630050078A /* Thumbhash.swift */; };
|
||||
FEC340D32E7326630050078A /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CF2E7326630050078A /* Request.swift */; };
|
||||
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CC2E7326630050078A /* ThumbnailResolver.swift */; };
|
||||
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CD2E7326630050078A /* Thumbnails.g.swift */; };
|
||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
|
||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -117,11 +115,9 @@
|
||||
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
|
||||
FEC340C92E7326630050078A /* AssetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResolver.swift; sourceTree = "<group>"; };
|
||||
FEC340CB2E7326630050078A /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailResolver.swift; sourceTree = "<group>"; };
|
||||
FEC340CD2E7326630050078A /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
|
||||
FEC340CF2E7326630050078A /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
|
||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -251,7 +247,6 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEC340D02E7326630050078A /* Resolvers */,
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
@@ -266,6 +261,7 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
FED3B1952E253E9B0030FD97 /* Images */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -300,34 +296,16 @@
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEC340CA2E7326630050078A /* Assets */ = {
|
||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEC340C92E7326630050078A /* AssetResolver.swift */,
|
||||
);
|
||||
path = Assets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEC340CE2E7326630050078A /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEC340CB2E7326630050078A /* Thumbhash.swift */,
|
||||
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */,
|
||||
FEC340CD2E7326630050078A /* Thumbnails.g.swift */,
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
|
||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
|
||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEC340D02E7326630050078A /* Resolvers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEC340CA2E7326630050078A /* Assets */,
|
||||
FEC340CE2E7326630050078A /* Images */,
|
||||
FEC340CF2E7326630050078A /* Request.swift */,
|
||||
);
|
||||
path = Resolvers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -595,16 +573,14 @@
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
|
||||
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
|
||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
|
||||
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
|
||||
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */,
|
||||
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */,
|
||||
FEC340D32E7326630050078A /* Request.swift in Sources */,
|
||||
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */,
|
||||
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import UIKit
|
||||
) -> Bool {
|
||||
// Required for flutter_local_notification
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
}
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
@@ -53,7 +53,7 @@ import UIKit
|
||||
|
||||
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
|
||||
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailResolver())
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||
}
|
||||
}
|
||||
|
||||
211
mobile/ios/Runner/Images/ThumbnailsImpl.swift
Normal file
211
mobile/ios/Runner/Images/ThumbnailsImpl.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
import CryptoKit
|
||||
import Flutter
|
||||
import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class Request {
|
||||
weak var workItem: DispatchWorkItem?
|
||||
var isCancelled = false
|
||||
let callback: (Result<[String: Int64], any Error>) -> Void
|
||||
|
||||
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailApiImpl: ThumbnailApi {
|
||||
private static let imageManager = PHImageManager.default()
|
||||
private static let fetchOptions = {
|
||||
let fetchOptions = PHFetchOptions()
|
||||
fetchOptions.fetchLimit = 1
|
||||
fetchOptions.wantsIncrementalChangeDetails = false
|
||||
return fetchOptions
|
||||
}()
|
||||
private static let requestOptions = {
|
||||
let requestOptions = PHImageRequestOptions()
|
||||
requestOptions.isNetworkAccessAllowed = true
|
||||
requestOptions.deliveryMode = .highQualityFormat
|
||||
requestOptions.resizeMode = .fast
|
||||
requestOptions.isSynchronous = true
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
|
||||
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
||||
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
||||
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
|
||||
|
||||
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
|
||||
private static var requests = [Int64: Request]()
|
||||
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
|
||||
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
|
||||
private static let assetCache = {
|
||||
let assetCache = NSCache<NSString, PHAsset>()
|
||||
assetCache.countLimit = 10000
|
||||
return assetCache
|
||||
}()
|
||||
private static let activitySemaphore = DispatchSemaphore(value: 1)
|
||||
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.suspend()
|
||||
activitySemaphore.wait()
|
||||
}
|
||||
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.resume()
|
||||
activitySemaphore.signal()
|
||||
}
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
Self.processingQueue.async {
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
|
||||
}
|
||||
}
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
let request = Request(callback: completion)
|
||||
let item = DispatchWorkItem {
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
Self.concurrencySemaphore.wait()
|
||||
defer {
|
||||
Self.concurrencySemaphore.signal()
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let asset = Self.requestAsset(assetId: assetId)
|
||||
else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
var image: UIImage?
|
||||
Self.imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
|
||||
contentMode: .aspectFill,
|
||||
options: Self.requestOptions,
|
||||
resultHandler: { (_image, info) -> Void in
|
||||
image = _image
|
||||
}
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
let pointer = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
|
||||
alignment: MemoryLayout<UInt8>.alignment
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let context = CGContext(
|
||||
data: pointer,
|
||||
width: cgImage.width,
|
||||
height: cgImage.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: cgImage.width * 4,
|
||||
space: Self.rgbColorSpace,
|
||||
bitmapInfo: Self.bitmapInfo
|
||||
) else {
|
||||
pointer.deallocate()
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
context.interpolationQuality = .none
|
||||
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
|
||||
Self.removeRequest(requestId: requestId)
|
||||
}
|
||||
|
||||
request.workItem = item
|
||||
Self.addRequest(requestId: requestId, request: request)
|
||||
Self.processingQueue.async(execute: item)
|
||||
}
|
||||
|
||||
func cancelImageRequest(requestId: Int64) {
|
||||
Self.cancelRequest(requestId: requestId)
|
||||
}
|
||||
|
||||
private static func addRequest(requestId: Int64, request: Request) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
private static func removeRequest(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
private static func cancelRequest(requestId: Int64) -> Void {
|
||||
requestQueue.async {
|
||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||
request.isCancelled = true
|
||||
guard let item = request.workItem else { return }
|
||||
if item.isCancelled {
|
||||
cancelQueue.async { request.callback(Self.cancelledResult) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestAsset(assetId: String) -> PHAsset? {
|
||||
var asset: PHAsset?
|
||||
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
||||
if asset != nil { return asset }
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
||||
else { return nil }
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
return asset
|
||||
}
|
||||
|
||||
func waitForActiveState() {
|
||||
Self.activitySemaphore.wait()
|
||||
Self.activitySemaphore.signal()
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import Photos
|
||||
|
||||
class AssetRequest: Request {
|
||||
let assetId: String
|
||||
var completion: (PHAsset?) -> Void
|
||||
|
||||
init(cancellationToken: CancellationToken, assetId: String, completion: @escaping (PHAsset?) -> Void) {
|
||||
self.assetId = assetId
|
||||
self.completion = completion
|
||||
super.init(cancellationToken: cancellationToken)
|
||||
}
|
||||
}
|
||||
|
||||
class AssetResolver {
|
||||
private let requestQueue: DispatchQueue
|
||||
private let processingQueue: DispatchQueue
|
||||
|
||||
private var batchTimer: DispatchWorkItem?
|
||||
private let batchLock = NSLock()
|
||||
private let batchTimeout: TimeInterval
|
||||
|
||||
private let fetchOptions: PHFetchOptions
|
||||
private var assetRequests = [AssetRequest]()
|
||||
private let assetCache: NSCache<NSString, PHAsset>
|
||||
|
||||
init(
|
||||
fetchOptions: PHFetchOptions,
|
||||
batchTimeout: TimeInterval = 0.00025, // 250μs
|
||||
cacheSize: Int = 10000,
|
||||
qos: DispatchQoS = .unspecified
|
||||
) {
|
||||
self.fetchOptions = fetchOptions
|
||||
self.batchTimeout = batchTimeout
|
||||
self.assetCache = NSCache<NSString, PHAsset>()
|
||||
self.assetCache.countLimit = cacheSize
|
||||
self.requestQueue = DispatchQueue(label: "assets.requests", qos: qos)
|
||||
self.processingQueue = DispatchQueue(label: "assets.processing", qos: qos)
|
||||
}
|
||||
|
||||
func requestAsset(request: AssetRequest) {
|
||||
requestQueue.async {
|
||||
if (request.isCancelled) {
|
||||
request.completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let cachedAsset = self.assetCache.object(forKey: request.assetId as NSString) {
|
||||
request.completion(cachedAsset)
|
||||
return
|
||||
}
|
||||
|
||||
self.batchLock.lock()
|
||||
if (request.isCancelled) {
|
||||
self.batchLock.unlock()
|
||||
request.completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
self.assetRequests.append(request)
|
||||
|
||||
self.batchTimer?.cancel()
|
||||
let timer = DispatchWorkItem(block: self.processBatch)
|
||||
self.batchTimer = timer
|
||||
self.batchLock.unlock()
|
||||
self.processingQueue.asyncAfter(deadline: .now() + self.batchTimeout, execute: timer)
|
||||
}
|
||||
}
|
||||
|
||||
private func processBatch() {
|
||||
batchLock.lock()
|
||||
if assetRequests.isEmpty {
|
||||
batchTimer = nil
|
||||
batchLock.unlock()
|
||||
return
|
||||
}
|
||||
|
||||
var completionMap = [String: [(PHAsset?) -> Void]]()
|
||||
var activeAssetIds = [String]()
|
||||
completionMap.reserveCapacity(assetRequests.count)
|
||||
activeAssetIds.reserveCapacity(assetRequests.count)
|
||||
for request in assetRequests {
|
||||
if (request.isCancelled) {
|
||||
request.completion(nil)
|
||||
continue
|
||||
}
|
||||
|
||||
if var completions = completionMap[request.assetId] {
|
||||
completions.append(request.completion)
|
||||
} else {
|
||||
activeAssetIds.append(request.assetId)
|
||||
completionMap[request.assetId] = [request.completion]
|
||||
}
|
||||
}
|
||||
assetRequests.removeAll(keepingCapacity: true)
|
||||
batchTimer = nil
|
||||
batchLock.unlock()
|
||||
|
||||
guard !activeAssetIds.isEmpty else { return }
|
||||
|
||||
let assets = PHAsset.fetchAssets(withLocalIdentifiers: activeAssetIds, options: self.fetchOptions)
|
||||
assets.enumerateObjects { asset, _, _ in
|
||||
let assetId = asset.localIdentifier
|
||||
for completion in completionMap.removeValue(forKey: assetId)! {
|
||||
completion(asset)
|
||||
}
|
||||
self.requestQueue.async { self.assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
}
|
||||
|
||||
for completions in completionMap.values {
|
||||
for completion in completions {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import CryptoKit
|
||||
import Flutter
|
||||
import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class ThumbnailRequest: Request {
|
||||
weak var workItem: DispatchWorkItem?
|
||||
let completion: (Result<[String: Int64], any Error>) -> Void
|
||||
|
||||
init(cancellationToken: CancellationToken, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
self.completion = completion
|
||||
super.init(cancellationToken: cancellationToken)
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailResolver: ThumbnailApi {
|
||||
private static let imageManager = PHImageManager.default()
|
||||
private static let assetResolver = AssetResolver(fetchOptions: {
|
||||
let fetchOptions = PHFetchOptions()
|
||||
fetchOptions.wantsIncrementalChangeDetails = false
|
||||
return fetchOptions
|
||||
}(), qos: .userInitiated)
|
||||
private static let requestOptions = {
|
||||
let requestOptions = PHImageRequestOptions()
|
||||
requestOptions.isNetworkAccessAllowed = true
|
||||
requestOptions.deliveryMode = .highQualityFormat
|
||||
requestOptions.resizeMode = .fast
|
||||
requestOptions.isSynchronous = true
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
|
||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
||||
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
||||
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
|
||||
|
||||
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
|
||||
private static var requests = [Int64: ThumbnailRequest]()
|
||||
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
|
||||
private static let thumbnailConcurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount / 2 + 1)
|
||||
private static let activitySemaphore = DispatchSemaphore(value: 1)
|
||||
|
||||
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.suspend()
|
||||
activitySemaphore.wait()
|
||||
}
|
||||
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
processingQueue.resume()
|
||||
activitySemaphore.signal()
|
||||
}
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
Self.processingQueue.async {
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
|
||||
}
|
||||
}
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
let cancellationToken = CancellationToken()
|
||||
let thumbnailRequest = ThumbnailRequest(cancellationToken: cancellationToken, completion: completion)
|
||||
Self.assetResolver.requestAsset(request: AssetRequest(cancellationToken: cancellationToken, assetId: assetId) { asset in
|
||||
if cancellationToken.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem {
|
||||
if cancellationToken.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let asset = asset else {
|
||||
if cancellationToken.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
Self.removeRequest(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
|
||||
Self.thumbnailConcurrencySemaphore.wait()
|
||||
defer { Self.thumbnailConcurrencySemaphore.signal() }
|
||||
|
||||
if cancellationToken.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
var image: UIImage?
|
||||
Self.imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
|
||||
contentMode: .aspectFill,
|
||||
options: Self.requestOptions,
|
||||
resultHandler: { (_image, info) -> Void in
|
||||
image = _image
|
||||
}
|
||||
)
|
||||
|
||||
if cancellationToken.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
let pointer = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
|
||||
alignment: MemoryLayout<UInt8>.alignment
|
||||
)
|
||||
|
||||
if cancellationToken.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let context = CGContext(
|
||||
data: pointer,
|
||||
width: cgImage.width,
|
||||
height: cgImage.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: cgImage.width * 4,
|
||||
space: Self.rgbColorSpace,
|
||||
bitmapInfo: Self.bitmapInfo
|
||||
) else {
|
||||
pointer.deallocate()
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
if cancellationToken.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
context.interpolationQuality = .none
|
||||
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
||||
|
||||
if cancellationToken.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
self.waitForActiveState()
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
|
||||
Self.removeRequest(requestId: requestId)
|
||||
}
|
||||
thumbnailRequest.workItem = item
|
||||
Self.processingQueue.async(execute: item)
|
||||
})
|
||||
|
||||
Self.addRequest(requestId: requestId, request: thumbnailRequest)
|
||||
}
|
||||
|
||||
func cancelImageRequest(requestId: Int64) {
|
||||
Self.cancelRequest(requestId: requestId)
|
||||
}
|
||||
|
||||
private static func addRequest(requestId: Int64, request: ThumbnailRequest) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
private static func removeRequest(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
private static func cancelRequest(requestId: Int64) -> Void {
|
||||
requestQueue.async {
|
||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||
request.isCancelled = true
|
||||
guard let item = request.workItem else { return }
|
||||
item.cancel()
|
||||
if item.isCancelled {
|
||||
cancelQueue.async { request.completion(Self.cancelledResult) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForActiveState() {
|
||||
Self.activitySemaphore.wait()
|
||||
Self.activitySemaphore.signal()
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
class CancellationToken {
|
||||
var isCancelled = false
|
||||
}
|
||||
|
||||
class Request {
|
||||
let cancellationToken: CancellationToken
|
||||
|
||||
init(cancellationToken: CancellationToken) {
|
||||
self.cancellationToken = cancellationToken
|
||||
}
|
||||
|
||||
var isCancelled: Bool {
|
||||
get {
|
||||
return cancellationToken.isCancelled
|
||||
}
|
||||
set(newValue) {
|
||||
cancellationToken.isCancelled = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ platform :ios do
|
||||
path: "./Runner.xcodeproj",
|
||||
)
|
||||
increment_version_number(
|
||||
version_number: "1.142.1"
|
||||
version_number: "1.143.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -10,11 +10,13 @@ import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/intl_keys.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
@@ -58,7 +60,7 @@ class BackgroundWorkerFgService {
|
||||
}
|
||||
|
||||
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
late final ProviderContainer _ref;
|
||||
ProviderContainer? _ref;
|
||||
final Isar _isar;
|
||||
final Drift _drift;
|
||||
final DriftLogger _driftLogger;
|
||||
@@ -83,29 +85,31 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
HttpSSLOptions.apply(applyNative: false);
|
||||
|
||||
await Future.wait([
|
||||
loadTranslations(),
|
||||
workerManager.init(dynamicSpawning: true),
|
||||
_ref.read(authServiceProvider).setOpenApiServiceEndpoint(),
|
||||
// Initialize the file downloader
|
||||
FileDownloader().configure(
|
||||
globalConfig: [
|
||||
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||
(Config.holdingQueue, (6, 6, 3)),
|
||||
// On Android, if files are larger than 256MB, run in foreground service
|
||||
(Config.runInForegroundIfFileLargerThan, 256),
|
||||
],
|
||||
),
|
||||
FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false),
|
||||
FileDownloader().trackTasks(),
|
||||
_ref.read(fileMediaRepositoryProvider).enableBackgroundAccess(),
|
||||
]);
|
||||
await Future.wait(
|
||||
[
|
||||
loadTranslations(),
|
||||
workerManager.init(dynamicSpawning: true),
|
||||
_ref?.read(authServiceProvider).setOpenApiServiceEndpoint(),
|
||||
// Initialize the file downloader
|
||||
FileDownloader().configure(
|
||||
globalConfig: [
|
||||
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
|
||||
(Config.holdingQueue, (6, 6, 3)),
|
||||
// On Android, if files are larger than 256MB, run in foreground service
|
||||
(Config.runInForegroundIfFileLargerThan, 256),
|
||||
],
|
||||
),
|
||||
FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false),
|
||||
FileDownloader().trackTasks(),
|
||||
_ref?.read(fileMediaRepositoryProvider).enableBackgroundAccess(),
|
||||
].nonNulls,
|
||||
);
|
||||
|
||||
configureFileDownloaderNotifications();
|
||||
|
||||
@@ -178,15 +182,17 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
if (_isCleanedUp) {
|
||||
// If ref is null, it means the service was never initialized properly
|
||||
if (_isCleanedUp || _ref == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref.read(nativeSyncApiProvider);
|
||||
_isCleanedUp = true;
|
||||
_ref.dispose();
|
||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||
_ref?.dispose();
|
||||
_ref = null;
|
||||
|
||||
_cancellationToken.cancel();
|
||||
_logger.info("Cleaning up background worker");
|
||||
@@ -199,14 +205,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
Store.dispose(),
|
||||
_drift.close(),
|
||||
_driftLogger.close(),
|
||||
backgroundSyncManager.cancel(),
|
||||
nativeSyncApi.cancelHashing(),
|
||||
backgroundSyncManager?.cancel(),
|
||||
nativeSyncApi?.cancelHashing(),
|
||||
];
|
||||
|
||||
if (_isar.isOpen) {
|
||||
cleanupFutures.add(_isar.close());
|
||||
}
|
||||
await Future.wait(cleanupFutures);
|
||||
await Future.wait(cleanupFutures.nonNulls);
|
||||
_logger.info("Background worker resources cleaned up");
|
||||
} catch (error, stack) {
|
||||
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
|
||||
@@ -216,14 +222,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
Future<void> _handleBackup() async {
|
||||
await runZonedGuarded(
|
||||
() async {
|
||||
if (!_isBackupEnabled || _isCleanedUp) {
|
||||
if (_isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isBackupEnabled) {
|
||||
_logger.info("[_handleBackup 1] Backup is disabled. Skipping backup routine");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.info("[_handleBackup 2] Enqueuing assets for backup from the background service");
|
||||
|
||||
final currentUser = _ref.read(currentUserProvider);
|
||||
final currentUser = _ref?.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
_logger.warning("[_handleBackup 3] No current user found. Skipping backup from background");
|
||||
return;
|
||||
@@ -231,19 +241,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
_logger.info("[_handleBackup 4] Resume backup from background");
|
||||
if (Platform.isIOS) {
|
||||
return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
|
||||
return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
|
||||
}
|
||||
|
||||
final canPing = await _ref.read(serverInfoServiceProvider).ping();
|
||||
final canPing = await _ref?.read(serverInfoServiceProvider).ping() ?? false;
|
||||
if (!canPing) {
|
||||
_logger.warning("[_handleBackup 5] Server is not reachable. Skipping backup from background");
|
||||
return;
|
||||
}
|
||||
|
||||
final networkCapabilities = await _ref.read(connectivityApiProvider).getCapabilities();
|
||||
|
||||
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
||||
return _ref
|
||||
.read(uploadServiceProvider)
|
||||
?.read(uploadServiceProvider)
|
||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken);
|
||||
},
|
||||
(error, stack) {
|
||||
@@ -253,18 +262,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
Future<void> _syncAssets({Duration? hashTimeout}) async {
|
||||
await _ref.read(backgroundSyncProvider).syncLocal();
|
||||
await _ref?.read(backgroundSyncProvider).syncLocal();
|
||||
if (_isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _ref.read(backgroundSyncProvider).syncRemote();
|
||||
await _ref?.read(backgroundSyncProvider).syncRemote();
|
||||
if (_isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hashFuture = _ref.read(backgroundSyncProvider).hashAssets();
|
||||
if (hashTimeout != null) {
|
||||
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
|
||||
if (hashTimeout != null && hashFuture != null) {
|
||||
hashFuture = hashFuture.timeout(
|
||||
hashTimeout,
|
||||
onTimeout: () {
|
||||
@@ -277,6 +286,23 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundWorkerLockService {
|
||||
final BackgroundWorkerLockApi _hostApi;
|
||||
const BackgroundWorkerLockService(this._hostApi);
|
||||
|
||||
Future<void> lock() async {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
return _hostApi.lock();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unlock() async {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
return _hostApi.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native entry invoked from the background worker. If renaming or moving this to a different
|
||||
/// library, make sure to update the entry points and URI in native workers as well
|
||||
@pragma('vm:entry-point')
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final syncLinkedAlbumServiceProvider = Provider(
|
||||
(ref) => SyncLinkedAlbumService(
|
||||
@@ -31,17 +31,19 @@ class SyncLinkedAlbumService {
|
||||
selectedAlbums.map((localAlbum) async {
|
||||
final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId;
|
||||
if (linkedRemoteAlbumId == null) {
|
||||
_log.warning("No linked remote album ID found for local album: ${localAlbum.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId);
|
||||
if (remoteAlbum == null) {
|
||||
_log.warning("Linked remote album not found for ID: $linkedRemoteAlbumId");
|
||||
return;
|
||||
}
|
||||
|
||||
// get assets that are uploaded but not in the remote album
|
||||
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
|
||||
|
||||
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
|
||||
if (assetIds.isNotEmpty) {
|
||||
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
|
||||
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
|
||||
final user = Store.tryGet(StoreKey.currentUser);
|
||||
if (user == null) {
|
||||
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
|
||||
return Future.value();
|
||||
}
|
||||
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
|
||||
|
||||
@@ -29,82 +29,56 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.excluded));
|
||||
}
|
||||
|
||||
Future<int> getTotalCount() async {
|
||||
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.localAlbumAssetEntity.assetId.isNotInQuery(_getExcludedSubquery()),
|
||||
);
|
||||
/// Returns all backup-related counts in a single query.
|
||||
///
|
||||
/// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums
|
||||
/// - backup: number of those assets that already exist on the server for [userId]
|
||||
/// - remainder: number of those assets that do not yet exist on the server for [userId]
|
||||
/// (includes processing)
|
||||
/// - processing: number of those assets that are still preparing/have a null checksum
|
||||
Future<({int total, int remainder, int processing})> getAllCounts(String userId) async {
|
||||
const sql = '''
|
||||
SELECT
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE lae.checksum IS NULL) AS processing_count,
|
||||
COUNT(*) FILTER (WHERE rae.id IS NULL) AS remainder_count
|
||||
FROM local_asset_entity lae
|
||||
LEFT JOIN main.remote_asset_entity rae
|
||||
ON lae.checksum = rae.checksum AND rae.owner_id = ?1
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM local_album_asset_entity laa
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?2
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM local_album_asset_entity laa
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?3
|
||||
);
|
||||
''';
|
||||
|
||||
return query.get().then((rows) => rows.length);
|
||||
}
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
sql,
|
||||
variables: [
|
||||
Variable.withString(userId),
|
||||
Variable.withInt(BackupSelection.selected.index),
|
||||
Variable.withInt(BackupSelection.excluded.index),
|
||||
],
|
||||
readsFrom: {_db.localAlbumAssetEntity, _db.localAlbumEntity, _db.localAssetEntity, _db.remoteAssetEntity},
|
||||
)
|
||||
.getSingle();
|
||||
|
||||
Future<int> getRemainderCount(String userId) async {
|
||||
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum) &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.id.isNull() &
|
||||
_db.localAlbumAssetEntity.assetId.isNotInQuery(_getExcludedSubquery()),
|
||||
);
|
||||
|
||||
return query.get().then((rows) => rows.length);
|
||||
}
|
||||
|
||||
Future<int> getBackupCount(String userId) async {
|
||||
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.id.isNotNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.localAlbumAssetEntity.assetId.isNotInQuery(_getExcludedSubquery()),
|
||||
);
|
||||
|
||||
return query.get().then((rows) => rows.length);
|
||||
final data = row.data;
|
||||
return (
|
||||
total: (data['total_count'] as int?) ?? 0,
|
||||
remainder: (data['remainder_count'] as int?) ?? 0,
|
||||
processing: (data['processing_count'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getCandidates(String userId) async {
|
||||
|
||||
@@ -12,9 +12,11 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
@@ -32,6 +34,7 @@ import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
import 'package:immich_mobile/utils/licenses.dart';
|
||||
import 'package:immich_mobile/utils/migration.dart';
|
||||
@@ -39,10 +42,10 @@ import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:timezone/data/latest.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
void main() async {
|
||||
ImmichWidgetsBinding();
|
||||
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
|
||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDb);
|
||||
await initApp();
|
||||
|
||||
@@ -16,6 +16,9 @@ import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftBackupPage extends ConsumerStatefulWidget {
|
||||
@@ -29,6 +32,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WakelockPlus.enable();
|
||||
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
return;
|
||||
@@ -44,6 +50,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
super.dispose();
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedAlbum = ref
|
||||
@@ -260,12 +272,205 @@ class _RemainderCard extends ConsumerWidget {
|
||||
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
|
||||
final syncStatus = ref.watch(syncStatusProvider);
|
||||
|
||||
return BackupInfoCard(
|
||||
title: "backup_controller_page_remainder".tr(),
|
||||
subtitle: "backup_controller_page_remainder_sub".tr(),
|
||||
info: remainderCount.toString(),
|
||||
isLoading: syncStatus.isRemoteSyncing,
|
||||
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
|
||||
),
|
||||
elevation: 0,
|
||||
borderOnForeground: false,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
minVerticalPadding: 18,
|
||||
isThreeLine: true,
|
||||
title: Text("backup_controller_page_remainder".t(context: context), style: context.textTheme.titleMedium),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
|
||||
child: Text(
|
||||
"backup_controller_page_remainder_sub".t(context: context),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Text(
|
||||
remainderCount.toString(),
|
||||
style: context.textTheme.titleLarge?.copyWith(
|
||||
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
||||
),
|
||||
),
|
||||
if (syncStatus.isRemoteSyncing)
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colorScheme.onSurface.withAlpha(150),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"backup_info_card_assets",
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 0),
|
||||
const _PreparingStatus(),
|
||||
const Divider(height: 0),
|
||||
|
||||
ListTile(
|
||||
enableFeedback: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 0.0),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
|
||||
),
|
||||
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
||||
title: Text(
|
||||
"view_details".t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||
),
|
||||
trailing: Icon(Icons.arrow_forward_ios, size: 16, color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreparingStatus extends ConsumerStatefulWidget {
|
||||
const _PreparingStatus();
|
||||
|
||||
@override
|
||||
_PreparingStatusState createState() => _PreparingStatusState();
|
||||
}
|
||||
|
||||
class _PreparingStatusState extends ConsumerState {
|
||||
Timer? _pollingTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollingTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startPollingIfNeeded() {
|
||||
if (_pollingTimer != null) return;
|
||||
|
||||
_pollingTimer = Timer.periodic(const Duration(seconds: 3), (timer) async {
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser != null && mounted) {
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
|
||||
// Stop polling if processing count reaches 0
|
||||
final updatedProcessingCount = ref.read(driftBackupProvider.select((p) => p.processingCount));
|
||||
if (updatedProcessingCount == 0) {
|
||||
timer.cancel();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
} else {
|
||||
timer.cancel();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final syncStatus = ref.watch(syncStatusProvider);
|
||||
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
|
||||
final processingCount = ref.watch(driftBackupProvider.select((p) => p.processingCount));
|
||||
final readyForUploadCount = remainderCount - processingCount;
|
||||
|
||||
ref.listen<int>(driftBackupProvider.select((p) => p.processingCount), (previous, next) {
|
||||
if (next > 0 && _pollingTimer == null) {
|
||||
_startPollingIfNeeded();
|
||||
} else if (next == 0 && _pollingTimer != null) {
|
||||
_pollingTimer?.cancel();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (!syncStatus.isHashing) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 1.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerHigh.withValues(alpha: 0.5),
|
||||
shape: BoxShape.rectangle,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"preparing".t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colorScheme.onSurface.withAlpha(200),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 1.5)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
processingCount.toString(),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
decoration: BoxDecoration(color: context.colorScheme.primary.withValues(alpha: 0.1)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"ready_for_upload".t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
readyForUploadCount.toString(),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
97
mobile/lib/platform/background_worker_lock_api.g.dart
generated
Normal file
97
mobile/lib/platform/background_worker_lock_api.g.dart
generated
Normal file
@@ -0,0 +1,97 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
PlatformException _createConnectionError(String channelName) {
|
||||
return PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundWorkerLockApi {
|
||||
/// Constructor for [BackgroundWorkerLockApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
BackgroundWorkerLockApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<void> lock() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unlock() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ class ShareActionButton extends ConsumerWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref.read(actionProvider.notifier).shareAssets(source).then((ActionResult result) {
|
||||
ref.read(actionProvider.notifier).shareAssets(source, context).then((ActionResult result) {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!context.mounted) {
|
||||
|
||||
@@ -16,9 +16,8 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
final String id;
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
final bool exact;
|
||||
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution, this.exact = true});
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -38,12 +37,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: key.size * devicePixelRatio,
|
||||
assetType: key.assetType,
|
||||
);
|
||||
final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
|
||||
return loadRequest(request, decode);
|
||||
}
|
||||
|
||||
@@ -51,7 +45,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalThumbProvider) {
|
||||
return id == other.id && (!exact || size == other.size);
|
||||
return id == other.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -66,12 +60,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
|
||||
LocalFullImageProvider({
|
||||
required this.id,
|
||||
required this.assetType,
|
||||
required this.size,
|
||||
LocalThumbProvider? initialProvider,
|
||||
});
|
||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -82,7 +71,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: id, assetType: assetType, exact: false)),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:ui';
|
||||
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||
const Size kThumbnailResolution = Size.square(128);
|
||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||
final size = Size.square(tileHeight);
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
@@ -135,7 +134,6 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -146,9 +144,8 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
class _AssetTileWidget extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final int assetIndex;
|
||||
final Size size;
|
||||
|
||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size});
|
||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
|
||||
|
||||
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
@@ -206,7 +203,6 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
lockSelection: lockSelection,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
heroOffset: heroOffset,
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final _scrollController = ScrollController();
|
||||
late final ScrollController _scrollController;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// Drag selection state
|
||||
@@ -119,10 +119,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
int _perRow = 4;
|
||||
double _scaleFactor = 3.0;
|
||||
double _baseScaleFactor = 3.0;
|
||||
int? _scaleRestoreAssetIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController(onAttach: _restoreScalePosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
|
||||
@@ -154,6 +156,28 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
void _restoreScalePosition(_) {
|
||||
if (_scaleRestoreAssetIndex == null) return;
|
||||
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _scaleRestoreAssetIndex!);
|
||||
if (targetSegment != null) {
|
||||
final assetIndexInSegment = _scaleRestoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
|
||||
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
|
||||
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
|
||||
final targetOffset = targetSegment.indexToLayoutOffset(targetRowIndex);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_scrollController.jumpTo(targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
_scaleRestoreAssetIndex = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
@@ -345,9 +369,28 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final currentOffset = _scrollController.offset.clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
|
||||
int? targetAssetIndex;
|
||||
if (segment != null) {
|
||||
final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset);
|
||||
if (rowIndex > segment.firstIndex) {
|
||||
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
|
||||
final assetsPerRow = ref.read(timelineArgsProvider).columnCount;
|
||||
final assetIndexInSegment = rowIndexInSegment * assetsPerRow;
|
||||
targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment;
|
||||
} else {
|
||||
targetAssetIndex = segment.firstAssetIndex;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_scaleRestoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -138,6 +139,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
|
||||
Future<void> _handleBetaTimelineResume() async {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
|
||||
|
||||
// Give isolates time to complete any ongoing database transactions
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
@@ -209,6 +211,9 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_pauseOperation = Completer<void>();
|
||||
|
||||
try {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
|
||||
}
|
||||
await _performPause();
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("Error during app pause", e, stackTrace);
|
||||
@@ -240,6 +245,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
Future<void> handleAppDetached() async {
|
||||
state = AppLifeCycleEnum.detached;
|
||||
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
|
||||
}
|
||||
|
||||
// Flush logs before closing database
|
||||
try {
|
||||
LogService.I.flush();
|
||||
|
||||
@@ -123,6 +123,7 @@ class DriftBackupState {
|
||||
final int totalCount;
|
||||
final int backupCount;
|
||||
final int remainderCount;
|
||||
final int processingCount;
|
||||
|
||||
final int enqueueCount;
|
||||
final int enqueueTotalCount;
|
||||
@@ -135,6 +136,7 @@ class DriftBackupState {
|
||||
required this.totalCount,
|
||||
required this.backupCount,
|
||||
required this.remainderCount,
|
||||
required this.processingCount,
|
||||
required this.enqueueCount,
|
||||
required this.enqueueTotalCount,
|
||||
required this.isCanceling,
|
||||
@@ -145,6 +147,7 @@ class DriftBackupState {
|
||||
int? totalCount,
|
||||
int? backupCount,
|
||||
int? remainderCount,
|
||||
int? processingCount,
|
||||
int? enqueueCount,
|
||||
int? enqueueTotalCount,
|
||||
bool? isCanceling,
|
||||
@@ -154,6 +157,7 @@ class DriftBackupState {
|
||||
totalCount: totalCount ?? this.totalCount,
|
||||
backupCount: backupCount ?? this.backupCount,
|
||||
remainderCount: remainderCount ?? this.remainderCount,
|
||||
processingCount: processingCount ?? this.processingCount,
|
||||
enqueueCount: enqueueCount ?? this.enqueueCount,
|
||||
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
|
||||
isCanceling: isCanceling ?? this.isCanceling,
|
||||
@@ -163,7 +167,7 @@ class DriftBackupState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)';
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -174,6 +178,7 @@ class DriftBackupState {
|
||||
return other.totalCount == totalCount &&
|
||||
other.backupCount == backupCount &&
|
||||
other.remainderCount == remainderCount &&
|
||||
other.processingCount == processingCount &&
|
||||
other.enqueueCount == enqueueCount &&
|
||||
other.enqueueTotalCount == enqueueTotalCount &&
|
||||
other.isCanceling == isCanceling &&
|
||||
@@ -185,6 +190,7 @@ class DriftBackupState {
|
||||
return totalCount.hashCode ^
|
||||
backupCount.hashCode ^
|
||||
remainderCount.hashCode ^
|
||||
processingCount.hashCode ^
|
||||
enqueueCount.hashCode ^
|
||||
enqueueTotalCount.hashCode ^
|
||||
isCanceling.hashCode ^
|
||||
@@ -203,6 +209,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
totalCount: 0,
|
||||
backupCount: 0,
|
||||
remainderCount: 0,
|
||||
processingCount: 0,
|
||||
enqueueCount: 0,
|
||||
enqueueTotalCount: 0,
|
||||
isCanceling: false,
|
||||
@@ -313,13 +320,14 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
|
||||
Future<void> getBackupStatus(String userId) async {
|
||||
final [totalCount, backupCount, remainderCount] = await Future.wait([
|
||||
_uploadService.getBackupTotalCount(),
|
||||
_uploadService.getBackupFinishedCount(userId),
|
||||
_uploadService.getBackupRemainderCount(userId),
|
||||
]);
|
||||
final counts = await _uploadService.getBackupCounts(userId);
|
||||
|
||||
state = state.copyWith(totalCount: totalCount, backupCount: backupCount, remainderCount: remainderCount);
|
||||
state = state.copyWith(
|
||||
totalCount: counts.total,
|
||||
backupCount: counts.total - counts.remainder,
|
||||
remainderCount: counts.remainder,
|
||||
processingCount: counts.processing,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> startBackup(String userId) {
|
||||
|
||||
@@ -342,11 +342,11 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> shareAssets(ActionSource source) async {
|
||||
Future<ActionResult> shareAssets(ActionSource source, BuildContext context) async {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
try {
|
||||
await _service.shareAssets(ids);
|
||||
await _service.shareAssets(ids, context);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to share assets', error, stack);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/thumbnail_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
|
||||
final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService>(
|
||||
(_) => BackgroundWorkerLockService(BackgroundWorkerLockApi()),
|
||||
);
|
||||
|
||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart' as asset_entity;
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
||||
@@ -68,7 +70,7 @@ class AssetMediaRepository {
|
||||
}
|
||||
|
||||
// TODO: make this more efficient
|
||||
Future<int> shareAssets(List<BaseAsset> assets) async {
|
||||
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) async {
|
||||
final downloadedXFiles = <XFile>[];
|
||||
|
||||
for (var asset in assets) {
|
||||
@@ -105,8 +107,12 @@ class AssetMediaRepository {
|
||||
}
|
||||
|
||||
// we dont want to await the share result since the
|
||||
// "preparing" dialog will not disappear unti
|
||||
Share.shareXFiles(downloadedXFiles).then((result) async {
|
||||
// "preparing" dialog will not disappear until
|
||||
final size = context.sizeData;
|
||||
Share.shareXFiles(
|
||||
downloadedXFiles,
|
||||
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
|
||||
).then((result) async {
|
||||
for (var file in downloadedXFiles) {
|
||||
try {
|
||||
await File(file.path).delete();
|
||||
|
||||
@@ -224,8 +224,8 @@ class ActionService {
|
||||
await _assetApiRepository.unStack(stackIds);
|
||||
}
|
||||
|
||||
Future<int> shareAssets(List<BaseAsset> assets) {
|
||||
return _assetMediaRepository.shareAssets(assets);
|
||||
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) {
|
||||
return _assetMediaRepository.shareAssets(assets, context);
|
||||
}
|
||||
|
||||
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
|
||||
|
||||
@@ -89,16 +89,8 @@ class UploadService {
|
||||
return _uploadRepository.getActiveTasks(group);
|
||||
}
|
||||
|
||||
Future<int> getBackupTotalCount() {
|
||||
return _backupRepository.getTotalCount();
|
||||
}
|
||||
|
||||
Future<int> getBackupRemainderCount(String userId) {
|
||||
return _backupRepository.getRemainderCount(userId);
|
||||
}
|
||||
|
||||
Future<int> getBackupFinishedCount(String userId) {
|
||||
return _backupRepository.getBackupCount(userId);
|
||||
Future<({int total, int remainder, int processing})> getBackupCounts(String userId) {
|
||||
return _backupRepository.getAllCounts(userId);
|
||||
}
|
||||
|
||||
Future<void> manualBackup(List<LocalAsset> localAssets) async {
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
@@ -193,6 +194,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
if (isBeta) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
handleSyncFlow();
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
context.replaceRoute(const TabShellRoute());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ pigeon:
|
||||
dart run pigeon --input pigeon/native_sync_api.dart
|
||||
dart run pigeon --input pigeon/thumbnail_api.dart
|
||||
dart run pigeon --input pigeon/background_worker_api.dart
|
||||
dart run pigeon --input pigeon/background_worker_lock_api.dart
|
||||
dart run pigeon --input pigeon/connectivity_api.dart
|
||||
dart format lib/platform/native_sync_api.g.dart
|
||||
dart format lib/platform/thumbnail_api.g.dart
|
||||
dart format lib/platform/background_worker_api.g.dart
|
||||
dart format lib/platform/background_worker_lock_api.g.dart
|
||||
dart format lib/platform/connectivity_api.g.dart
|
||||
|
||||
watch:
|
||||
|
||||
3
mobile/openapi/README.md
generated
3
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.142.1
|
||||
- API version: 1.143.0
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
@@ -393,6 +393,7 @@ Class | Method | HTTP request | Description
|
||||
- [LoginCredentialDto](doc//LoginCredentialDto.md)
|
||||
- [LoginResponseDto](doc//LoginResponseDto.md)
|
||||
- [LogoutResponseDto](doc//LogoutResponseDto.md)
|
||||
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
||||
- [ManualJobName](doc//ManualJobName.md)
|
||||
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
|
||||
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
|
||||
|
||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@@ -164,6 +164,7 @@ part 'model/log_level.dart';
|
||||
part 'model/login_credential_dto.dart';
|
||||
part 'model/login_response_dto.dart';
|
||||
part 'model/logout_response_dto.dart';
|
||||
part 'model/machine_learning_availability_checks_dto.dart';
|
||||
part 'model/manual_job_name.dart';
|
||||
part 'model/map_marker_response_dto.dart';
|
||||
part 'model/map_reverse_geocode_response_dto.dart';
|
||||
|
||||
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@@ -382,6 +382,8 @@ class ApiClient {
|
||||
return LoginResponseDto.fromJson(value);
|
||||
case 'LogoutResponseDto':
|
||||
return LogoutResponseDto.fromJson(value);
|
||||
case 'MachineLearningAvailabilityChecksDto':
|
||||
return MachineLearningAvailabilityChecksDto.fromJson(value);
|
||||
case 'ManualJobName':
|
||||
return ManualJobNameTypeTransformer().decode(value);
|
||||
case 'MapMarkerResponseDto':
|
||||
|
||||
115
mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart
generated
Normal file
115
mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class MachineLearningAvailabilityChecksDto {
|
||||
/// Returns a new [MachineLearningAvailabilityChecksDto] instance.
|
||||
MachineLearningAvailabilityChecksDto({
|
||||
required this.enabled,
|
||||
required this.interval,
|
||||
required this.timeout,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
num interval;
|
||||
|
||||
num timeout;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
|
||||
other.enabled == enabled &&
|
||||
other.interval == interval &&
|
||||
other.timeout == timeout;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(interval.hashCode) +
|
||||
(timeout.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MachineLearningAvailabilityChecksDto[enabled=$enabled, interval=$interval, timeout=$timeout]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'interval'] = this.interval;
|
||||
json[r'timeout'] = this.timeout;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MachineLearningAvailabilityChecksDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MachineLearningAvailabilityChecksDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MachineLearningAvailabilityChecksDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MachineLearningAvailabilityChecksDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
interval: num.parse('${json[r'interval']}'),
|
||||
timeout: num.parse('${json[r'timeout']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MachineLearningAvailabilityChecksDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MachineLearningAvailabilityChecksDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MachineLearningAvailabilityChecksDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MachineLearningAvailabilityChecksDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MachineLearningAvailabilityChecksDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MachineLearningAvailabilityChecksDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MachineLearningAvailabilityChecksDto-objects as value to a dart map
|
||||
static Map<String, List<MachineLearningAvailabilityChecksDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MachineLearningAvailabilityChecksDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MachineLearningAvailabilityChecksDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'interval',
|
||||
'timeout',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,14 +13,16 @@ part of openapi.api;
|
||||
class SystemConfigMachineLearningDto {
|
||||
/// Returns a new [SystemConfigMachineLearningDto] instance.
|
||||
SystemConfigMachineLearningDto({
|
||||
required this.availabilityChecks,
|
||||
required this.clip,
|
||||
required this.duplicateDetection,
|
||||
required this.enabled,
|
||||
required this.facialRecognition,
|
||||
this.url,
|
||||
this.urls = const [],
|
||||
});
|
||||
|
||||
MachineLearningAvailabilityChecksDto availabilityChecks;
|
||||
|
||||
CLIPConfig clip;
|
||||
|
||||
DuplicateDetectionConfig duplicateDetection;
|
||||
@@ -29,50 +31,37 @@ class SystemConfigMachineLearningDto {
|
||||
|
||||
FacialRecognitionConfig facialRecognition;
|
||||
|
||||
/// This property was deprecated in v1.122.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? url;
|
||||
|
||||
List<String> urls;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigMachineLearningDto &&
|
||||
other.availabilityChecks == availabilityChecks &&
|
||||
other.clip == clip &&
|
||||
other.duplicateDetection == duplicateDetection &&
|
||||
other.enabled == enabled &&
|
||||
other.facialRecognition == facialRecognition &&
|
||||
other.url == url &&
|
||||
_deepEquality.equals(other.urls, urls);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(availabilityChecks.hashCode) +
|
||||
(clip.hashCode) +
|
||||
(duplicateDetection.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(facialRecognition.hashCode) +
|
||||
(url == null ? 0 : url!.hashCode) +
|
||||
(urls.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigMachineLearningDto[clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, url=$url, urls=$urls]';
|
||||
String toString() => 'SystemConfigMachineLearningDto[availabilityChecks=$availabilityChecks, clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, urls=$urls]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'availabilityChecks'] = this.availabilityChecks;
|
||||
json[r'clip'] = this.clip;
|
||||
json[r'duplicateDetection'] = this.duplicateDetection;
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'facialRecognition'] = this.facialRecognition;
|
||||
if (this.url != null) {
|
||||
json[r'url'] = this.url;
|
||||
} else {
|
||||
// json[r'url'] = null;
|
||||
}
|
||||
json[r'urls'] = this.urls;
|
||||
return json;
|
||||
}
|
||||
@@ -86,11 +75,11 @@ class SystemConfigMachineLearningDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigMachineLearningDto(
|
||||
availabilityChecks: MachineLearningAvailabilityChecksDto.fromJson(json[r'availabilityChecks'])!,
|
||||
clip: CLIPConfig.fromJson(json[r'clip'])!,
|
||||
duplicateDetection: DuplicateDetectionConfig.fromJson(json[r'duplicateDetection'])!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
facialRecognition: FacialRecognitionConfig.fromJson(json[r'facialRecognition'])!,
|
||||
url: mapValueOfType<String>(json, r'url'),
|
||||
urls: json[r'urls'] is Iterable
|
||||
? (json[r'urls'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
@@ -141,6 +130,7 @@ class SystemConfigMachineLearningDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'availabilityChecks',
|
||||
'clip',
|
||||
'duplicateDetection',
|
||||
'enabled',
|
||||
|
||||
17
mobile/pigeon/background_worker_lock_api.dart
Normal file
17
mobile/pigeon/background_worker_lock_api.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/background_worker_lock_api.g.dart',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.background', includeErrorClass: false),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class BackgroundWorkerLockApi {
|
||||
void lock();
|
||||
|
||||
void unlock();
|
||||
}
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 1.142.1+3015
|
||||
version: 1.143.0+3016
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -9858,7 +9858,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.142.1",
|
||||
"version": "1.143.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -12259,6 +12259,25 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MachineLearningAvailabilityChecksDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"interval": {
|
||||
"type": "number"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"interval",
|
||||
"timeout"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ManualJobName": {
|
||||
"enum": [
|
||||
"person-cleanup",
|
||||
@@ -16395,6 +16414,9 @@
|
||||
},
|
||||
"SystemConfigMachineLearningDto": {
|
||||
"properties": {
|
||||
"availabilityChecks": {
|
||||
"$ref": "#/components/schemas/MachineLearningAvailabilityChecksDto"
|
||||
},
|
||||
"clip": {
|
||||
"$ref": "#/components/schemas/CLIPConfig"
|
||||
},
|
||||
@@ -16407,11 +16429,6 @@
|
||||
"facialRecognition": {
|
||||
"$ref": "#/components/schemas/FacialRecognitionConfig"
|
||||
},
|
||||
"url": {
|
||||
"deprecated": true,
|
||||
"description": "This property was deprecated in v1.122.0",
|
||||
"type": "string"
|
||||
},
|
||||
"urls": {
|
||||
"format": "uri",
|
||||
"items": {
|
||||
@@ -16423,6 +16440,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"availabilityChecks",
|
||||
"clip",
|
||||
"duplicateDetection",
|
||||
"enabled",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.142.1",
|
||||
"version": "1.143.0",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 1.142.1
|
||||
* 1.143.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -1383,6 +1383,11 @@ export type SystemConfigLoggingDto = {
|
||||
enabled: boolean;
|
||||
level: LogLevel;
|
||||
};
|
||||
export type MachineLearningAvailabilityChecksDto = {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
timeout: number;
|
||||
};
|
||||
export type ClipConfig = {
|
||||
enabled: boolean;
|
||||
modelName: string;
|
||||
@@ -1399,12 +1404,11 @@ export type FacialRecognitionConfig = {
|
||||
modelName: string;
|
||||
};
|
||||
export type SystemConfigMachineLearningDto = {
|
||||
availabilityChecks: MachineLearningAvailabilityChecksDto;
|
||||
clip: ClipConfig;
|
||||
duplicateDetection: DuplicateDetectionConfig;
|
||||
enabled: boolean;
|
||||
facialRecognition: FacialRecognitionConfig;
|
||||
/** This property was deprecated in v1.122.0 */
|
||||
url?: string;
|
||||
urls: string[];
|
||||
};
|
||||
export type SystemConfigMapDto = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
2610
pnpm-lock.yaml
generated
2610
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509091104@sha256:4f9275330f1e49e7ce9840758ea91839052fe6ed40972d5bb97a9af857fa956a AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
@@ -33,7 +33,7 @@ RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install &&
|
||||
pnpm --filter @immich/sdk --filter @immich/cli build && \
|
||||
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202509091104@sha256:d1ccbac24c84f2f8277cf85281edfca62d85d7daed6a62b8efd3a81bcd3c5e0e
|
||||
FROM ghcr.io/immich-app/base-server-prod:202509210934@sha256:0c7eacf0ba88ca52e1a267cfc62d20d07792ea2c604818c2cbd37dc7dcefdac9
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509091104@sha256:4f9275330f1e49e7ce9840758ea91839052fe6ed40972d5bb97a9af857fa956a AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.142.1",
|
||||
"version": "1.143.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -44,14 +44,14 @@
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.203.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.203.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.51.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.49.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.56.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.205.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.205.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.53.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.51.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.58.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.203.0",
|
||||
"@opentelemetry/sdk-node": "^0.205.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { repositories } from 'src/repositories';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
|
||||
import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
@@ -57,7 +58,7 @@ class SqlGenerator {
|
||||
try {
|
||||
await this.setup();
|
||||
for (const Repository of repositories) {
|
||||
if (Repository === LoggingRepository) {
|
||||
if (Repository === LoggingRepository || Repository === MachineLearningRepository) {
|
||||
continue;
|
||||
}
|
||||
await this.process(Repository);
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface SystemConfig {
|
||||
machineLearning: {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
availabilityChecks: {
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
interval: number;
|
||||
};
|
||||
clip: {
|
||||
enabled: boolean;
|
||||
modelName: string;
|
||||
@@ -176,6 +181,8 @@ export interface SystemConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
||||
|
||||
export const defaults = Object.freeze<SystemConfig>({
|
||||
backup: {
|
||||
database: {
|
||||
@@ -227,6 +234,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
machineLearning: {
|
||||
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
|
||||
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
|
||||
availabilityChecks: {
|
||||
enabled: true,
|
||||
timeout: Number(process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT) || 2000,
|
||||
interval: 30_000,
|
||||
},
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
|
||||
@@ -51,11 +51,6 @@ export const serverVersion = new SemVer(version);
|
||||
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
|
||||
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
|
||||
|
||||
export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000);
|
||||
export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number(
|
||||
process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000,
|
||||
);
|
||||
|
||||
export const citiesFile = 'cities500.txt';
|
||||
export const reverseGeocodeMaxDistance = 25_000;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PropertyLifecycle } from 'src/decorators';
|
||||
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
class BaseSearchDto {
|
||||
@ValidateUUID({ optional: true, nullable: true })
|
||||
@@ -144,9 +144,7 @@ export class MetadataSearchDto extends RandomSearchDto {
|
||||
@Optional()
|
||||
deviceAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@@ -154,9 +152,7 @@ export class MetadataSearchDto extends RandomSearchDto {
|
||||
@Optional()
|
||||
checksum?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
originalFileName?: string;
|
||||
|
||||
@IsString()
|
||||
@@ -190,16 +186,12 @@ export class MetadataSearchDto extends RandomSearchDto {
|
||||
}
|
||||
|
||||
export class StatisticsSearchDto extends BaseSearchDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class SmartSearchDto extends BaseSearchWithResultsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
query?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Exclude, Transform, Type } from 'class-transformer';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsInt,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { PropertyLifecycle } from 'src/decorators';
|
||||
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
|
||||
import {
|
||||
AudioCodec,
|
||||
@@ -257,21 +256,32 @@ class SystemConfigLoggingDto {
|
||||
level!: LogLevel;
|
||||
}
|
||||
|
||||
class MachineLearningAvailabilityChecksDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsInt()
|
||||
timeout!: number;
|
||||
|
||||
@IsInt()
|
||||
interval!: number;
|
||||
}
|
||||
|
||||
class SystemConfigMachineLearningDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@PropertyLifecycle({ deprecatedAt: 'v1.122.0' })
|
||||
@Exclude()
|
||||
url?: string;
|
||||
|
||||
@IsUrl({ require_tld: false, allow_underscores: true }, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
@Transform(({ obj, value }) => (obj.url ? [obj.url] : value))
|
||||
@ValidateIf((dto) => dto.enabled)
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 })
|
||||
urls!: string[];
|
||||
|
||||
@Type(() => MachineLearningAvailabilityChecksDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
availabilityChecks!: MachineLearningAvailabilityChecksDto;
|
||||
|
||||
@Type(() => CLIPConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
||||
@@ -142,6 +142,10 @@ export class LoggingRepository {
|
||||
this.handleMessage(LogLevel.Fatal, message, details);
|
||||
}
|
||||
|
||||
deprecate(message: string) {
|
||||
this.warn(`[Deprecated] ${message}`);
|
||||
}
|
||||
|
||||
private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) {
|
||||
if (this.logger.isLevelEnabled(level)) {
|
||||
this.handleMessage(level, message(), details);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Duration } from 'luxon';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants';
|
||||
import { MachineLearningConfig } from 'src/config';
|
||||
import { CLIPConfig } from 'src/dtos/model-config.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
@@ -57,82 +58,100 @@ export type TextEncodingOptions = ModelOptions & { language?: string };
|
||||
|
||||
@Injectable()
|
||||
export class MachineLearningRepository {
|
||||
// Note that deleted URL's are not removed from this map (ie: they're leaked)
|
||||
// Cleaning them up is low priority since there should be very few over a
|
||||
// typical server uptime cycle
|
||||
private urlAvailability: {
|
||||
[url: string]:
|
||||
| {
|
||||
active: boolean;
|
||||
lastChecked: number;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
private healthyMap: Record<string, boolean> = {};
|
||||
private interval?: ReturnType<typeof setInterval>;
|
||||
private _config?: MachineLearningConfig;
|
||||
|
||||
private get config(): MachineLearningConfig {
|
||||
if (!this._config) {
|
||||
throw new Error('Machine learning repository not been setup');
|
||||
}
|
||||
|
||||
return this._config;
|
||||
}
|
||||
|
||||
constructor(private logger: LoggingRepository) {
|
||||
this.logger.setContext(MachineLearningRepository.name);
|
||||
this.urlAvailability = {};
|
||||
}
|
||||
|
||||
private setUrlAvailability(url: string, active: boolean) {
|
||||
const current = this.urlAvailability[url];
|
||||
if (current?.active !== active) {
|
||||
this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`);
|
||||
setup(config: MachineLearningConfig) {
|
||||
this._config = config;
|
||||
this.teardown();
|
||||
|
||||
// delete old servers
|
||||
for (const url of Object.keys(this.healthyMap)) {
|
||||
if (!config.urls.includes(url)) {
|
||||
delete this.healthyMap[url];
|
||||
}
|
||||
}
|
||||
this.urlAvailability[url] = {
|
||||
active,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
|
||||
if (!config.availabilityChecks.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tick();
|
||||
this.interval = setInterval(
|
||||
() => this.tick(),
|
||||
Duration.fromObject({ milliseconds: config.availabilityChecks.interval }).as('milliseconds'),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkAvailability(url: string) {
|
||||
let active = false;
|
||||
teardown() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
private tick() {
|
||||
for (const url of this.config.urls) {
|
||||
void this.check(url);
|
||||
}
|
||||
}
|
||||
|
||||
private async check(url: string) {
|
||||
let healthy = false;
|
||||
try {
|
||||
const response = await fetch(new URL('/ping', url), {
|
||||
signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT),
|
||||
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
|
||||
});
|
||||
active = response.ok;
|
||||
if (response.ok) {
|
||||
healthy = true;
|
||||
}
|
||||
} catch {
|
||||
// nothing to do here
|
||||
}
|
||||
this.setUrlAvailability(url, active);
|
||||
return active;
|
||||
|
||||
this.setHealthy(url, healthy);
|
||||
}
|
||||
|
||||
private async shouldSkipUrl(url: string) {
|
||||
const availability = this.urlAvailability[url];
|
||||
if (availability === undefined) {
|
||||
// If this is a new endpoint, then check inline and skip if it fails
|
||||
if (!(await this.checkAvailability(url))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
private setHealthy(url: string, healthy: boolean) {
|
||||
if (this.healthyMap[url] !== healthy) {
|
||||
this.logger.log(`Machine learning server became ${healthy ? 'healthy' : 'unhealthy'} (${url}).`);
|
||||
}
|
||||
if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) {
|
||||
// If this is an old inactive endpoint that hasn't been checked in a
|
||||
// while then check but don't wait for the result, just skip it
|
||||
// This avoids delays on every search whilst allowing higher priority
|
||||
// ML servers to recover over time.
|
||||
void this.checkAvailability(url);
|
||||
|
||||
this.healthyMap[url] = healthy;
|
||||
}
|
||||
|
||||
private isHealthy(url: string) {
|
||||
if (!this.config.availabilityChecks.enabled) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
return this.healthyMap[url];
|
||||
}
|
||||
|
||||
private async predict<T>(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
|
||||
private async predict<T>(payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
|
||||
const formData = await this.getFormData(payload, config);
|
||||
let urlCounter = 0;
|
||||
for (const url of urls) {
|
||||
urlCounter++;
|
||||
const isLast = urlCounter >= urls.length;
|
||||
if (!isLast && (await this.shouldSkipUrl(url))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const url of [
|
||||
// try healthy servers first
|
||||
...this.config.urls.filter((url) => this.isHealthy(url)),
|
||||
...this.config.urls.filter((url) => !this.isHealthy(url)),
|
||||
]) {
|
||||
try {
|
||||
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
this.setUrlAvailability(url, true);
|
||||
this.setHealthy(url, true);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -144,20 +163,21 @@ export class MachineLearningRepository {
|
||||
`Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
this.setUrlAvailability(url, false);
|
||||
|
||||
this.setHealthy(url, false);
|
||||
}
|
||||
|
||||
throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`);
|
||||
}
|
||||
|
||||
async detectFaces(urls: string[], imagePath: string, { modelName, minScore }: FaceDetectionOptions) {
|
||||
async detectFaces(imagePath: string, { modelName, minScore }: FaceDetectionOptions) {
|
||||
const request = {
|
||||
[ModelTask.FACIAL_RECOGNITION]: {
|
||||
[ModelType.DETECTION]: { modelName, options: { minScore } },
|
||||
[ModelType.RECOGNITION]: { modelName },
|
||||
},
|
||||
};
|
||||
const response = await this.predict<FacialRecognitionResponse>(urls, { imagePath }, request);
|
||||
const response = await this.predict<FacialRecognitionResponse>({ imagePath }, request);
|
||||
return {
|
||||
imageHeight: response.imageHeight,
|
||||
imageWidth: response.imageWidth,
|
||||
@@ -165,15 +185,15 @@ export class MachineLearningRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async encodeImage(urls: string[], imagePath: string, { modelName }: CLIPConfig) {
|
||||
async encodeImage(imagePath: string, { modelName }: CLIPConfig) {
|
||||
const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
|
||||
const response = await this.predict<ClipVisualResponse>(urls, { imagePath }, request);
|
||||
const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
|
||||
return response[ModelTask.SEARCH];
|
||||
}
|
||||
|
||||
async encodeText(urls: string[], text: string, { language, modelName }: TextEncodingOptions) {
|
||||
async encodeText(text: string, { language, modelName }: TextEncodingOptions) {
|
||||
const request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName, options: { language } } } };
|
||||
const response = await this.predict<ClipTextualResponse>(urls, { text }, request);
|
||||
const response = await this.predict<ClipTextualResponse>({ text }, request);
|
||||
return response[ModelTask.SEARCH];
|
||||
}
|
||||
|
||||
|
||||
@@ -729,7 +729,6 @@ describe(PersonService.name, () => {
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
|
||||
['http://immich-machine-learning:3003'],
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
|
||||
);
|
||||
|
||||
@@ -316,7 +316,6 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces(
|
||||
machineLearning.urls,
|
||||
previewFile.path,
|
||||
machineLearning.facialRecognition,
|
||||
);
|
||||
|
||||
@@ -211,7 +211,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ modelName: expect.any(String) }),
|
||||
);
|
||||
@@ -225,7 +224,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ modelName: expect.any(String) }),
|
||||
);
|
||||
@@ -243,7 +241,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }),
|
||||
);
|
||||
@@ -253,7 +250,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ language: 'de' }),
|
||||
);
|
||||
|
||||
@@ -118,7 +118,7 @@ export class SearchService extends BaseService {
|
||||
const key = machineLearning.clip.modelName + dto.query + dto.language;
|
||||
embedding = this.embeddingCache.get(key);
|
||||
if (!embedding) {
|
||||
embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, {
|
||||
embedding = await this.machineLearningRepository.encodeText(dto.query, {
|
||||
modelName: machineLearning.clip.modelName,
|
||||
language: dto.language,
|
||||
});
|
||||
|
||||
@@ -205,7 +205,6 @@ describe(SmartInfoService.name, () => {
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success);
|
||||
|
||||
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
|
||||
['http://immich-machine-learning:3003'],
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
|
||||
);
|
||||
@@ -242,7 +241,6 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
expect(mocks.database.wait).toHaveBeenCalledWith(512);
|
||||
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
|
||||
['http://immich-machine-learning:3003'],
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
|
||||
);
|
||||
|
||||
@@ -108,11 +108,7 @@ export class SmartInfoService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const embedding = await this.machineLearningRepository.encodeImage(
|
||||
machineLearning.urls,
|
||||
asset.files[0].path,
|
||||
machineLearning.clip,
|
||||
);
|
||||
const embedding = await this.machineLearningRepository.encodeImage(asset.files[0].path, machineLearning.clip);
|
||||
|
||||
if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) {
|
||||
this.logger.verbose(`Waiting for CLIP dimension size to be updated`);
|
||||
|
||||
@@ -82,6 +82,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
machineLearning: {
|
||||
enabled: true,
|
||||
urls: ['http://immich-machine-learning:3003'],
|
||||
availabilityChecks: {
|
||||
enabled: true,
|
||||
interval: 30_000,
|
||||
timeout: 2000,
|
||||
},
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
|
||||
@@ -16,6 +16,20 @@ export class SystemConfigService extends BaseService {
|
||||
async onBootstrap() {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
await this.eventRepository.emit('ConfigInit', { newConfig: config });
|
||||
|
||||
if (
|
||||
process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT ||
|
||||
process.env.IMMICH_MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME
|
||||
) {
|
||||
this.logger.deprecate(
|
||||
'IMMICH_MACHINE_LEARNING_PING_TIMEOUT and MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME have been moved to system config(`machineLearning.availabilityChecks`) and will be removed in a future release.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppShutdown' })
|
||||
onShutdown() {
|
||||
this.machineLearningRepository.teardown();
|
||||
}
|
||||
|
||||
async getSystemConfig(): Promise<SystemConfigDto> {
|
||||
@@ -28,12 +42,14 @@ export class SystemConfigService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigInit', priority: -100 })
|
||||
onConfigInit({ newConfig: { logging } }: ArgOf<'ConfigInit'>) {
|
||||
onConfigInit({ newConfig: { logging, machineLearning } }: ArgOf<'ConfigInit'>) {
|
||||
const { logLevel: envLevel } = this.configRepository.getEnv();
|
||||
const configLevel = logging.enabled ? logging.level : false;
|
||||
const level = envLevel ?? configLevel;
|
||||
this.logger.setLogLevel(level);
|
||||
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`);
|
||||
|
||||
this.machineLearningRepository.setup(machineLearning);
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigUpdate', server: true })
|
||||
|
||||
@@ -211,6 +211,18 @@ export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => {
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type StringOptions = { optional?: boolean; nullable?: boolean; trim?: boolean };
|
||||
export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, trim, ...apiPropertyOptions } = options || {};
|
||||
const decorators = [ApiProperty(apiPropertyOptions), IsString(), optional ? Optional({ nullable }) : IsNotEmpty()];
|
||||
|
||||
if (trim) {
|
||||
decorators.push(Transform(({ value }: { value: string }) => value?.trim()));
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type BooleanOptions = { optional?: boolean; nullable?: boolean };
|
||||
export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, ...apiPropertyOptions } = options || {};
|
||||
|
||||
@@ -127,6 +127,7 @@ export default typescriptEslint.config(
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.142.1",
|
||||
"version": "1.143.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -55,7 +55,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"simple-icons": "^15.15.0",
|
||||
"socket.io-client": "~4.8.0",
|
||||
"svelte-gestures": "^5.1.3",
|
||||
"svelte-gestures": "^5.2.2",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-maplibre": "^1.2.0",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.8.0",
|
||||
"@sveltejs/kit": "^2.27.1",
|
||||
"@sveltejs/vite-plugin-svelte": "6.1.2",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/svelte": "^5.2.8",
|
||||
@@ -85,7 +85,7 @@
|
||||
"dotenv": "^17.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-p": "^0.25.0",
|
||||
"eslint-p": "^0.26.0",
|
||||
"eslint-plugin-compat": "^6.0.2",
|
||||
"eslint-plugin-svelte": "^3.9.0",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
@@ -97,7 +97,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.35.5",
|
||||
"svelte": "5.38.10",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.2.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
import { mdiMinusCircle } from '@mdi/js';
|
||||
import { mdiPlus, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -46,19 +46,6 @@
|
||||
|
||||
<div>
|
||||
{#each config.machineLearning.urls as _, i (i)}
|
||||
{#snippet removeButton()}
|
||||
{#if config.machineLearning.urls.length > 1}
|
||||
<IconButton
|
||||
size="large"
|
||||
shape="round"
|
||||
color="danger"
|
||||
aria-label=""
|
||||
onclick={() => config.machineLearning.urls.splice(i, 1)}
|
||||
icon={mdiMinusCircle}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={i === 0 ? $t('url') : undefined}
|
||||
@@ -67,20 +54,69 @@
|
||||
required={i === 0}
|
||||
disabled={disabled || !config.machineLearning.enabled}
|
||||
isEdited={i === 0 && !isEqual(config.machineLearning.urls, savedConfig.machineLearning.urls)}
|
||||
trailingSnippet={removeButton}
|
||||
/>
|
||||
>
|
||||
{#snippet trailingSnippet()}
|
||||
{#if config.machineLearning.urls.length > 1}
|
||||
<IconButton
|
||||
aria-label=""
|
||||
onclick={() => config.machineLearning.urls.splice(i, 1)}
|
||||
icon={mdiTrashCanOutline}
|
||||
color="danger"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="mb-2"
|
||||
size="small"
|
||||
shape="round"
|
||||
onclick={() => config.machineLearning.urls.splice(0, 0, '')}
|
||||
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
class="mb-2"
|
||||
size="small"
|
||||
shape="round"
|
||||
leadingIcon={mdiPlus}
|
||||
onclick={() => config.machineLearning.urls.push('')}
|
||||
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingAccordion
|
||||
key="availability-checks"
|
||||
title={$t('admin.machine_learning_availability_checks')}
|
||||
subtitle={$t('admin.machine_learning_availability_checks_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.machine_learning_availability_checks_enabled')}
|
||||
bind:checked={config.machineLearning.availabilityChecks.enabled}
|
||||
disabled={disabled || !config.machineLearning.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_availability_checks_interval')}
|
||||
bind:value={config.machineLearning.availabilityChecks.interval}
|
||||
description={$t('admin.machine_learning_availability_checks_interval_description')}
|
||||
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.availabilityChecks.enabled}
|
||||
isEdited={config.machineLearning.availabilityChecks.interval !==
|
||||
savedConfig.machineLearning.availabilityChecks.interval}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.machine_learning_availability_checks_timeout')}
|
||||
bind:value={config.machineLearning.availabilityChecks.timeout}
|
||||
description={$t('admin.machine_learning_availability_checks_timeout_description')}
|
||||
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.availabilityChecks.enabled}
|
||||
isEdited={config.machineLearning.availabilityChecks.timeout !==
|
||||
savedConfig.machineLearning.availabilityChecks.timeout}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="smart-search"
|
||||
title={$t('admin.machine_learning_smart_search')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte';
|
||||
import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
@@ -262,7 +263,7 @@
|
||||
values={{ job: $t('admin.storage_template_migration_job') }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<a href={AppRoute.ADMIN_JOBS} class="text-primary">
|
||||
<a href={resolve(AppRoute.ADMIN_JOBS)} class="text-primary">
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||
@@ -65,7 +66,7 @@
|
||||
{#each albums as album, index (album.id)}
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
href="{AppRoute.ALBUMS}/{album.id}"
|
||||
href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}
|
||||
animate:flip={{ duration: 400 }}
|
||||
oncontextmenu={(event) => oncontextmenu(event, album)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
@@ -315,7 +316,7 @@
|
||||
button: {
|
||||
text: $t('view_album'),
|
||||
onClick() {
|
||||
return goto(`${AppRoute.ALBUMS}/${album.id}`);
|
||||
return goto(resolve(`${AppRoute.ALBUMS}/${album.id}`));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { AppRoute, dateFormats } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
@@ -32,7 +33,7 @@
|
||||
|
||||
<tr
|
||||
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
|
||||
onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
|
||||
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
|
||||
{oncontextmenu}
|
||||
>
|
||||
<td class="text-md text-ellipsis text-start w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
@@ -146,7 +147,10 @@
|
||||
|
||||
<div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div>
|
||||
{#if assetId === undefined && reaction.assetId}
|
||||
<a class="aspect-square w-[75px] h-[75px]" href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}">
|
||||
<a
|
||||
class="aspect-square w-[75px] h-[75px]"
|
||||
href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetId}`)}
|
||||
>
|
||||
<img
|
||||
class="rounded-lg w-[75px] h-[75px] object-cover"
|
||||
src={getAssetThumbnailUrl(reaction.assetId)}
|
||||
@@ -198,7 +202,7 @@
|
||||
{#if assetId === undefined && reaction.assetId}
|
||||
<a
|
||||
class="aspect-square w-[75px] h-[75px]"
|
||||
href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}"
|
||||
href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetId}`)}
|
||||
>
|
||||
<img
|
||||
class="rounded-lg w-[75px] h-[75px] object-cover"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import CastButton from '$lib/cast/cast-button.svelte';
|
||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
||||
@@ -22,6 +23,7 @@
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
@@ -150,7 +152,7 @@
|
||||
onclick={onZoomImage}
|
||||
/>
|
||||
{/if}
|
||||
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
|
||||
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image && $photoViewerImgElement}
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
@@ -224,14 +226,15 @@
|
||||
{#if !asset.isArchived && !asset.isTrashed}
|
||||
<MenuOption
|
||||
icon={mdiImageSearch}
|
||||
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
|
||||
onClick={() => goto(resolve(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`))}
|
||||
text={$t('view_in_timeline')}
|
||||
/>
|
||||
{/if}
|
||||
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
|
||||
<MenuOption
|
||||
icon={mdiCompare}
|
||||
onClick={() => goto(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`)}
|
||||
onClick={() =>
|
||||
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
|
||||
text={$t('view_similar_photos')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
@@ -45,7 +46,7 @@
|
||||
<div class="flex group transition-all">
|
||||
<a
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)}
|
||||
href={resolve(`${AppRoute.TAGS}/?path=${encodeURI(tag.value)}`)}
|
||||
>
|
||||
<p class="text-sm">
|
||||
{tag.value}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
|
||||
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
|
||||
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
||||
@@ -208,9 +209,11 @@
|
||||
{#if showingHiddenPeople || !person.isHidden}
|
||||
<a
|
||||
class="w-[90px]"
|
||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
|
||||
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
|
||||
: AppRoute.PHOTOS}"
|
||||
href={resolve(
|
||||
`${AppRoute.PEOPLE}/${person.id}?${QueryParameter.PREVIOUS_ROUTE}=${
|
||||
currentAlbum?.id ? `${AppRoute.ALBUMS}/${currentAlbum?.id}` : AppRoute.PHOTOS
|
||||
}`,
|
||||
)}
|
||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onblur={() => ($boundingBoxesArray = [])}
|
||||
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
@@ -362,6 +365,7 @@
|
||||
</p>
|
||||
{#if showAssetPath}
|
||||
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve this is supposed to be treated as an absolute/external link -->
|
||||
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
|
||||
{asset.originalPath}
|
||||
</a>
|
||||
@@ -394,10 +398,12 @@
|
||||
{#if asset.exifInfo?.make || asset.exifInfo?.model}
|
||||
<p>
|
||||
<a
|
||||
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({
|
||||
...(asset.exifInfo?.make ? { make: asset.exifInfo.make } : {}),
|
||||
...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}),
|
||||
})}"
|
||||
href={resolve(
|
||||
`${AppRoute.SEARCH}?${getMetadataSearchQuery({
|
||||
...(asset.exifInfo?.make ? { make: asset.exifInfo.make } : {}),
|
||||
...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}),
|
||||
})}`,
|
||||
)}
|
||||
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
|
||||
class="hover:text-primary"
|
||||
>
|
||||
@@ -411,7 +417,9 @@
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
<a
|
||||
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}"
|
||||
href={resolve(
|
||||
`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`,
|
||||
)}
|
||||
title="{$t('search_for')} {asset.exifInfo.lensModel}"
|
||||
class="hover:text-primary line-clamp-1"
|
||||
>
|
||||
@@ -475,7 +483,7 @@
|
||||
simplified
|
||||
useLocationPin
|
||||
showSimpleControls={!showEditFaces}
|
||||
onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)}
|
||||
onOpenInMapView={() => goto(resolve(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`))}
|
||||
>
|
||||
{#snippet popup({ marker })}
|
||||
{@const { lat, lon } = marker}
|
||||
@@ -516,7 +524,7 @@
|
||||
<section class="px-6 pt-6 dark:text-immich-dark-fg">
|
||||
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
|
||||
{#each albums as album (album.id)}
|
||||
<a href="{AppRoute.ALBUMS}/{album.id}">
|
||||
<a href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}>
|
||||
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
|
||||
<div>
|
||||
<img
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
@@ -92,17 +92,13 @@
|
||||
};
|
||||
|
||||
copyImage = async () => {
|
||||
if (!canCopyImageToClipboard()) {
|
||||
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('copied_image_to_clipboard'),
|
||||
timeout: 3000,
|
||||
});
|
||||
await copyImageToClipboard($photoViewerImgElement);
|
||||
notificationController.show({ type: NotificationType.Info, message: $t('copied_image_to_clipboard') });
|
||||
} catch (error) {
|
||||
handleError(error, $t('copy_error'));
|
||||
}
|
||||
@@ -238,9 +234,8 @@
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction
|
||||
use:swipe={() => ({})}
|
||||
onswipe={onSwipe}
|
||||
class="h-full w-full flex"
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
@@ -255,7 +250,7 @@
|
||||
bind:this={$photoViewerImgElement}
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="max-h-full max-w-full h-auto w-auto mx-auto my-auto {$slideshowState === SlideshowState.None
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { swipe } from 'svelte-gestures';
|
||||
import { useSwipe } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
@@ -131,6 +131,13 @@
|
||||
document.removeEventListener('webkitfullscreenchange', exitFullscreenHandler);
|
||||
};
|
||||
});
|
||||
|
||||
const { swipe, onswipe, onswipedown } = useSwipe(
|
||||
() => {},
|
||||
() => ({ touchAction: 'pan-x' }),
|
||||
{ onswipedown: showControlBar },
|
||||
true,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -153,7 +160,8 @@
|
||||
]}
|
||||
/>
|
||||
|
||||
<svelte:body use:swipe={() => ({ touchAction: 'pan-x' })} onswipedown={showControlBar} />
|
||||
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}
|
||||
<svelte:body {@attach swipe} {onswipe} {onswipedown} />
|
||||
|
||||
{#if showControls}
|
||||
<div
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { swipe } from 'svelte-gestures';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
@@ -130,8 +129,7 @@
|
||||
playsinline
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
use:swipe={() => ({})}
|
||||
onswipe={onSwipe}
|
||||
{...useSwipe(onSwipe)}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -14,31 +15,27 @@
|
||||
}
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
|
||||
|
||||
const inputClasses = $derived(
|
||||
`immich-form-input w-full mt-1 hover:cursor-pointer ${invalid ? 'border border-danger' : ''}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
|
||||
<label class="immich-form-label" for="start-date">
|
||||
<span class="uppercase">{$t('start_date')}</span>
|
||||
<DateInput
|
||||
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
||||
type="date"
|
||||
id="start-date"
|
||||
name="start-date"
|
||||
max={filters.takenBefore}
|
||||
bind:value={filters.takenAfter}
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
|
||||
<label class="immich-form-label" for="start-date">
|
||||
<span class="uppercase">{$t('start_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="start-date" name="start-date" bind:value={filters.takenAfter} />
|
||||
</label>
|
||||
|
||||
<label class="immich-form-label" for="end-date">
|
||||
<span class="uppercase">{$t('end_date')}</span>
|
||||
<DateInput
|
||||
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
||||
type="date"
|
||||
id="end-date"
|
||||
name="end-date"
|
||||
placeholder=""
|
||||
min={filters.takenAfter}
|
||||
bind:value={filters.takenBefore}
|
||||
/>
|
||||
</label>
|
||||
<label class="immich-form-label" for="end-date">
|
||||
<span class="uppercase">{$t('end_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="end-date" name="end-date" bind:value={filters.takenBefore} />
|
||||
</label>
|
||||
</div>
|
||||
{#if invalid}
|
||||
<Text color="danger">{$t('start_date_before_end_date')}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
{/if}
|
||||
|
||||
{#if inputType !== SettingInputFieldType.PASSWORD}
|
||||
<div class="flex place-items-center place-content-center">
|
||||
<div class="flex place-items-center place-content-center gap-2">
|
||||
{#if inputType === SettingInputFieldType.COLOR}
|
||||
<input
|
||||
bind:this={input}
|
||||
|
||||
@@ -17,6 +17,9 @@ describe('RecentAlbums component', () => {
|
||||
render(RecentAlbums);
|
||||
|
||||
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
|
||||
|
||||
// wtf
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
|
||||
@@ -620,12 +620,7 @@ const imgToBlob = async (imageElement: HTMLImageElement) => {
|
||||
throw new Error('Canvas context is null');
|
||||
};
|
||||
|
||||
const urlToBlob = async (imageSource: string) => {
|
||||
const response = await fetch(imageSource);
|
||||
return await response.blob();
|
||||
};
|
||||
|
||||
export const copyImageToClipboard = async (source: HTMLImageElement | string) => {
|
||||
const blob = source instanceof HTMLImageElement ? await imgToBlob(source) : await urlToBlob(source);
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
||||
export const copyImageToClipboard = async (source: HTMLImageElement) => {
|
||||
// do not await, so the Safari clipboard write happens in the context of the user gesture
|
||||
await navigator.clipboard.write([new ClipboardItem({ ['image/png']: imgToBlob(source) })]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user